├── .nvmrc ├── .prettierrc ├── .vscode └── tasks.json ├── LICENSE.md ├── README.md ├── package.json ├── packages ├── laravel-echo │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ │ ├── channel │ │ │ ├── channel.ts │ │ │ ├── index.ts │ │ │ ├── null-channel.ts │ │ │ ├── null-encrypted-private-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 │ │ │ ├── connector.ts │ │ │ ├── index.ts │ │ │ ├── null-connector.ts │ │ │ ├── pusher-connector.ts │ │ │ └── socketio-connector.ts │ │ ├── echo.ts │ │ ├── index.iife.ts │ │ └── util │ │ │ ├── event-formatter.ts │ │ │ └── index.ts │ ├── tests │ │ ├── channel │ │ │ └── socketio-channel.test.ts │ │ ├── echo.test.ts │ │ └── util │ │ │ └── event-formatter.test.ts │ ├── tsconfig.json │ ├── typings │ │ ├── index.d.ts │ │ └── window.d.ts │ └── vite.config.ts ├── react │ ├── README.md │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ │ ├── config │ │ │ └── index.ts │ │ ├── hooks │ │ │ └── use-echo.ts │ │ ├── index.iife.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── util │ │ │ └── index.ts │ │ └── vite-env.d.ts │ ├── tests │ │ ├── config.test.ts │ │ └── use-echo.test.ts │ ├── tsconfig.json │ └── vite.config.ts └── vue │ ├── README.md │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ ├── composables │ │ └── useEcho.ts │ ├── config │ │ └── index.ts │ ├── index.iife.ts │ ├── index.ts │ ├── types.ts │ ├── util │ │ └── index.ts │ └── vite-env.d.ts │ ├── tests │ ├── config.test.ts │ └── useEcho.test.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── release.sh /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "path": ".", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "problemMatcher": [], 13 | "label": "pnpm: build", 14 | "detail": "vite build && FORMAT=iife vite build" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Laravel Echo library for beautiful Pusher and Socket.IO integration", 3 | "homepage": "https://github.com/laravel/echo", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/laravel/echo" 7 | }, 8 | "license": "MIT", 9 | "author": { 10 | "name": "Taylor Otwell" 11 | }, 12 | "scripts": { 13 | "test": "pnpm -r --if-present run test", 14 | "build": "pnpm -r --if-present run build", 15 | "lint": "pnpm -r --if-present run lint", 16 | "format": "pnpm -r --if-present run format", 17 | "compile": "pnpm -r --if-present run compile", 18 | "declarations": "pnpm -r --if-present run declarations" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/laravel-echo/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | 4 | const config = [ 5 | { 6 | ignores: ["dist/**/*"], 7 | files: ["src/**/*.ts"], 8 | languageOptions: { 9 | parser: tsParser, // Use the imported parser object 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | project: "./tsconfig.json", // Path to your TypeScript configuration file 14 | }, 15 | }, 16 | plugins: { 17 | "@typescript-eslint": tsPlugin, 18 | }, 19 | rules: { 20 | ...tsPlugin.configs.recommended.rules, 21 | ...tsPlugin.configs["recommended-requiring-type-checking"].rules, 22 | "@typescript-eslint/ban-types": "off", 23 | "@typescript-eslint/no-empty-object-type": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/no-floating-promises": "error", 26 | "@typescript-eslint/no-unsafe-argument": "warn", 27 | "@typescript-eslint/no-unsafe-assignment": "warn", 28 | "@typescript-eslint/no-unsafe-call": "warn", 29 | "@typescript-eslint/no-unsafe-function-type": "off", 30 | "@typescript-eslint/no-unsafe-member-access": "warn", 31 | "@typescript-eslint/no-unsafe-return": "warn", 32 | "@typescript-eslint/no-unused-vars": [ 33 | "warn", 34 | { argsIgnorePattern: "^_" }, 35 | ], 36 | "no-console": "warn", 37 | "prefer-const": "off", 38 | }, 39 | }, 40 | ]; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /packages/laravel-echo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-echo", 3 | "version": "2.1.5", 4 | "description": "Laravel Echo library for beautiful Pusher and Socket.IO integration", 5 | "keywords": [ 6 | "laravel", 7 | "pusher", 8 | "ably" 9 | ], 10 | "homepage": "https://github.com/laravel/echo/tree/2.x/packages/laravel-echo", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/laravel/echo" 14 | }, 15 | "license": "MIT", 16 | "author": { 17 | "name": "Taylor Otwell" 18 | }, 19 | "type": "module", 20 | "main": "dist/echo.common.js", 21 | "module": "dist/echo.js", 22 | "types": "dist/echo.d.ts", 23 | "scripts": { 24 | "build": "vite build && FORMAT=iife vite build", 25 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 26 | "prepublish": "pnpm run build", 27 | "release": "vitest --run && git push --follow-tags && pnpm publish", 28 | "test": "vitest", 29 | "format": "prettier --write ." 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.26.7", 33 | "@babel/plugin-proposal-decorators": "^7.25.9", 34 | "@babel/plugin-proposal-function-sent": "^7.25.9", 35 | "@babel/plugin-proposal-throw-expressions": "^7.25.9", 36 | "@babel/plugin-transform-export-namespace-from": "^7.25.9", 37 | "@babel/plugin-transform-numeric-separator": "^7.25.9", 38 | "@babel/plugin-transform-object-assign": "^7.25.9", 39 | "@babel/preset-env": "^7.26.7", 40 | "@types/jquery": "^3.5.32", 41 | "@types/node": "^20.0.0", 42 | "@typescript-eslint/eslint-plugin": "^8.21.0", 43 | "@typescript-eslint/parser": "^8.21.0", 44 | "axios": "^1.9.0", 45 | "eslint": "^9.0.0", 46 | "prettier": "^3.5.3", 47 | "pusher-js": "^8.0", 48 | "socket.io-client": "^4.0", 49 | "tslib": "^2.8.1", 50 | "typescript": "^5.7.0", 51 | "vite": "^5.0.0", 52 | "vite-plugin-dts": "^3.0.0", 53 | "vitest": "^3.1.2" 54 | }, 55 | "peerDependencies": { 56 | "pusher-js": "*", 57 | "socket.io-client": "*" 58 | }, 59 | "typesVersions": { 60 | "*": { 61 | "socket.io-client": [], 62 | "pusher-js": [] 63 | } 64 | }, 65 | "engines": { 66 | "node": ">=20" 67 | }, 68 | "exports": { 69 | ".": { 70 | "types": "./dist/echo.d.ts", 71 | "import": "./dist/echo.js", 72 | "require": "./dist/echo.common.js" 73 | }, 74 | "./iife": "./dist/echo.iife.js" 75 | }, 76 | "overrides": { 77 | "glob": "^9.0.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/channel.ts: -------------------------------------------------------------------------------- 1 | import type { EchoOptionsWithDefaults } from "../connector"; 2 | import type { BroadcastDriver } from "../echo"; 3 | 4 | /** 5 | * This class represents a basic channel. 6 | */ 7 | export abstract class Channel { 8 | /** 9 | * The Echo options. 10 | */ 11 | options: EchoOptionsWithDefaults; 12 | 13 | /** 14 | * Listen for an event on the channel instance. 15 | */ 16 | abstract listen(event: string, callback: CallableFunction): this; 17 | 18 | /** 19 | * Listen for a whisper event on the channel instance. 20 | */ 21 | listenForWhisper(event: string, callback: CallableFunction): this { 22 | return this.listen(".client-" + event, callback); 23 | } 24 | 25 | /** 26 | * Listen for an event on the channel instance. 27 | */ 28 | notification(callback: CallableFunction): this { 29 | return this.listen( 30 | ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated", 31 | callback, 32 | ); 33 | } 34 | 35 | /** 36 | * Stop listening to an event on the channel instance. 37 | */ 38 | abstract stopListening(event: string, callback?: CallableFunction): this; 39 | 40 | /** 41 | * Stop listening for a whisper event on the channel instance. 42 | */ 43 | stopListeningForWhisper(event: string, callback?: CallableFunction): this { 44 | return this.stopListening(".client-" + event, callback); 45 | } 46 | 47 | /** 48 | * Register a callback to be called anytime a subscription succeeds. 49 | */ 50 | abstract subscribed(callback: CallableFunction): this; 51 | 52 | /** 53 | * Register a callback to be called anytime an error occurs. 54 | */ 55 | abstract error(callback: CallableFunction): this; 56 | } 57 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./channel"; 2 | export * from "./presence-channel"; 3 | export * from "./pusher-channel"; 4 | export * from "./pusher-private-channel"; 5 | export * from "./pusher-encrypted-private-channel"; 6 | export * from "./pusher-presence-channel"; 7 | export * from "./socketio-channel"; 8 | export * from "./socketio-private-channel"; 9 | export * from "./socketio-presence-channel"; 10 | export * from "./null-channel"; 11 | export * from "./null-private-channel"; 12 | export * from "./null-encrypted-private-channel"; 13 | export * from "./null-presence-channel"; 14 | -------------------------------------------------------------------------------- /packages/laravel-echo/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(): void { 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: CallableFunction): this { 25 | return this; 26 | } 27 | 28 | /** 29 | * Listen for all events on the channel instance. 30 | */ 31 | listenToAll(_callback: CallableFunction): this { 32 | return this; 33 | } 34 | 35 | /** 36 | * Stop listening for an event on the channel instance. 37 | */ 38 | stopListening(_event: string, _callback?: CallableFunction): this { 39 | return this; 40 | } 41 | 42 | /** 43 | * Register a callback to be called anytime a subscription succeeds. 44 | */ 45 | subscribed(_callback: CallableFunction): this { 46 | return this; 47 | } 48 | 49 | /** 50 | * Register a callback to be called anytime an error occurs. 51 | */ 52 | error(_callback: CallableFunction): this { 53 | return this; 54 | } 55 | 56 | /** 57 | * Bind a channel to an event. 58 | */ 59 | on(_event: string, _callback: CallableFunction): this { 60 | return this; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/null-encrypted-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { NullChannel } from "./null-channel"; 2 | 3 | /** 4 | * This class represents a null private channel. 5 | */ 6 | export class NullEncryptedPrivateChannel extends NullChannel { 7 | /** 8 | * Send a whisper event to other clients in the channel. 9 | */ 10 | whisper(_eventName: string, _data: Record): this { 11 | return this; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/null-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import { NullPrivateChannel } from "./null-private-channel"; 2 | import type { PresenceChannel } from "./presence-channel"; 3 | 4 | /** 5 | * This class represents a null presence channel. 6 | */ 7 | export class NullPresenceChannel 8 | extends NullPrivateChannel 9 | implements PresenceChannel 10 | { 11 | /** 12 | * Register a callback to be called anytime the member list changes. 13 | */ 14 | here(_callback: CallableFunction): this { 15 | return this; 16 | } 17 | 18 | /** 19 | * Listen for someone joining the channel. 20 | */ 21 | joining(_callback: CallableFunction): this { 22 | return this; 23 | } 24 | 25 | /** 26 | * Send a whisper event to other clients in the channel. 27 | */ 28 | whisper(_eventName: string, _data: Record): this { 29 | return this; 30 | } 31 | 32 | /** 33 | * Listen for someone leaving the channel. 34 | */ 35 | leaving(_callback: CallableFunction): this { 36 | return this; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/laravel-echo/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: Record): this { 11 | return this; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/presence-channel.ts: -------------------------------------------------------------------------------- 1 | import type { 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: CallableFunction): this; 11 | 12 | /** 13 | * Listen for someone joining the channel. 14 | */ 15 | joining(callback: CallableFunction): this; 16 | 17 | /** 18 | * Send a whisper event to other clients in the channel. 19 | */ 20 | whisper(eventName: string, data: Record): this; 21 | 22 | /** 23 | * Listen for someone leaving the channel. 24 | */ 25 | leaving(callback: CallableFunction): this; 26 | } 27 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/pusher-channel.ts: -------------------------------------------------------------------------------- 1 | import { EventFormatter } from "../util"; 2 | import { Channel } from "./channel"; 3 | import type Pusher from "pusher-js"; 4 | import type { Channel as BasePusherChannel } from "pusher-js"; 5 | import type { EchoOptionsWithDefaults } from "../connector"; 6 | import type { BroadcastDriver } from "../echo"; 7 | 8 | /** 9 | * This class represents a Pusher channel. 10 | */ 11 | export class PusherChannel< 12 | TBroadcastDriver extends BroadcastDriver, 13 | > extends Channel { 14 | /** 15 | * The Pusher client instance. 16 | */ 17 | pusher: Pusher; 18 | 19 | /** 20 | * The name of the channel. 21 | */ 22 | name: string; 23 | 24 | /** 25 | * The event formatter. 26 | */ 27 | eventFormatter: EventFormatter; 28 | 29 | /** 30 | * The subscription of the channel. 31 | */ 32 | subscription: BasePusherChannel; 33 | 34 | /** 35 | * Create a new class instance. 36 | */ 37 | constructor( 38 | pusher: Pusher, 39 | name: string, 40 | options: EchoOptionsWithDefaults, 41 | ) { 42 | super(); 43 | 44 | this.name = name; 45 | this.pusher = pusher; 46 | this.options = options; 47 | this.eventFormatter = new EventFormatter(this.options.namespace); 48 | 49 | this.subscribe(); 50 | } 51 | 52 | /** 53 | * Subscribe to a Pusher channel. 54 | */ 55 | subscribe(): void { 56 | this.subscription = this.pusher.subscribe(this.name); 57 | } 58 | 59 | /** 60 | * Unsubscribe from a Pusher channel. 61 | */ 62 | unsubscribe(): void { 63 | this.pusher.unsubscribe(this.name); 64 | } 65 | 66 | /** 67 | * Listen for an event on the channel instance. 68 | */ 69 | listen(event: string, callback: CallableFunction): this { 70 | this.on(this.eventFormatter.format(event), callback); 71 | 72 | return this; 73 | } 74 | 75 | /** 76 | * Listen for all events on the channel instance. 77 | */ 78 | listenToAll(callback: CallableFunction): this { 79 | this.subscription.bind_global((event: string, data: unknown) => { 80 | if (event.startsWith("pusher:")) { 81 | return; 82 | } 83 | 84 | let namespace = String(this.options.namespace ?? "").replace( 85 | /\./g, 86 | "\\", 87 | ); 88 | 89 | let formattedEvent = event.startsWith(namespace) 90 | ? event.substring(namespace.length + 1) 91 | : "." + event; 92 | 93 | callback(formattedEvent, data); 94 | }); 95 | 96 | return this; 97 | } 98 | 99 | /** 100 | * Stop listening for an event on the channel instance. 101 | */ 102 | stopListening(event: string, callback?: CallableFunction): this { 103 | if (callback) { 104 | this.subscription.unbind( 105 | this.eventFormatter.format(event), 106 | callback, 107 | ); 108 | } else { 109 | this.subscription.unbind(this.eventFormatter.format(event)); 110 | } 111 | 112 | return this; 113 | } 114 | 115 | /** 116 | * Stop listening for all events on the channel instance. 117 | */ 118 | stopListeningToAll(callback?: CallableFunction): this { 119 | if (callback) { 120 | this.subscription.unbind_global(callback); 121 | } else { 122 | this.subscription.unbind_global(); 123 | } 124 | 125 | return this; 126 | } 127 | 128 | /** 129 | * Register a callback to be called anytime a subscription succeeds. 130 | */ 131 | subscribed(callback: CallableFunction): this { 132 | this.on("pusher:subscription_succeeded", () => { 133 | callback(); 134 | }); 135 | 136 | return this; 137 | } 138 | 139 | /** 140 | * Register a callback to be called anytime a subscription error occurs. 141 | */ 142 | error(callback: CallableFunction): this { 143 | this.on("pusher:subscription_error", (status: Record) => { 144 | callback(status); 145 | }); 146 | 147 | return this; 148 | } 149 | 150 | /** 151 | * Bind a channel to an event. 152 | */ 153 | on(event: string, callback: CallableFunction): this { 154 | this.subscription.bind(event, callback); 155 | 156 | return this; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/pusher-encrypted-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { PusherChannel } from "./pusher-channel"; 2 | import type { BroadcastDriver } from "../echo"; 3 | 4 | /** 5 | * This class represents a Pusher private channel. 6 | */ 7 | export class PusherEncryptedPrivateChannel< 8 | TBroadcastDriver extends BroadcastDriver, 9 | > extends PusherChannel { 10 | /** 11 | * Send a whisper event to other clients in the channel. 12 | */ 13 | whisper(eventName: string, data: Record): this { 14 | this.pusher.channels.channels[this.name].trigger( 15 | `client-${eventName}`, 16 | data, 17 | ); 18 | 19 | return this; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/pusher-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import type { PresenceChannel } from "./presence-channel"; 2 | import { PusherPrivateChannel } from "./pusher-private-channel"; 3 | import type { BroadcastDriver } from "../echo"; 4 | 5 | /** 6 | * This class represents a Pusher presence channel. 7 | */ 8 | export class PusherPresenceChannel 9 | extends PusherPrivateChannel 10 | implements PresenceChannel 11 | { 12 | /** 13 | * Register a callback to be called anytime the member list changes. 14 | */ 15 | here(callback: CallableFunction): this { 16 | this.on("pusher:subscription_succeeded", (data: Record) => { 17 | callback(Object.keys(data.members).map((k) => data.members[k])); 18 | }); 19 | 20 | return this; 21 | } 22 | 23 | /** 24 | * Listen for someone joining the channel. 25 | */ 26 | joining(callback: CallableFunction): this { 27 | this.on("pusher:member_added", (member: Record) => { 28 | callback(member.info); 29 | }); 30 | 31 | return this; 32 | } 33 | 34 | /** 35 | * Send a whisper event to other clients in the channel. 36 | */ 37 | whisper(eventName: string, data: Record): this { 38 | this.pusher.channels.channels[this.name].trigger( 39 | `client-${eventName}`, 40 | data, 41 | ); 42 | 43 | return this; 44 | } 45 | 46 | /** 47 | * Listen for someone leaving the channel. 48 | */ 49 | leaving(callback: CallableFunction): this { 50 | this.on("pusher:member_removed", (member: Record) => { 51 | callback(member.info); 52 | }); 53 | 54 | return this; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/pusher-private-channel.ts: -------------------------------------------------------------------------------- 1 | import { PusherChannel } from "./pusher-channel"; 2 | import type { BroadcastDriver } from "../echo"; 3 | 4 | /** 5 | * This class represents a Pusher private channel. 6 | */ 7 | export class PusherPrivateChannel< 8 | TBroadcastDriver extends BroadcastDriver, 9 | > extends PusherChannel { 10 | /** 11 | * Send a whisper event to other clients in the channel. 12 | */ 13 | whisper(eventName: string, data: Record): this { 14 | this.pusher.channels.channels[this.name].trigger( 15 | `client-${eventName}`, 16 | data, 17 | ); 18 | 19 | return this; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/socketio-channel.ts: -------------------------------------------------------------------------------- 1 | import { EventFormatter } from "../util"; 2 | import { Channel } from "./channel"; 3 | import type { Socket } from "socket.io-client"; 4 | import type { EchoOptionsWithDefaults } from "../connector"; 5 | import type { BroadcastDriver } from "../echo"; 6 | 7 | /** 8 | * This class represents a Socket.io channel. 9 | */ 10 | export class SocketIoChannel extends Channel { 11 | /** 12 | * The Socket.io client instance. 13 | */ 14 | socket: Socket; 15 | 16 | /** 17 | * The name of the channel. 18 | */ 19 | name: string; 20 | 21 | /** 22 | * The event formatter. 23 | */ 24 | eventFormatter: EventFormatter; 25 | 26 | /** 27 | * The event callbacks applied to the socket. 28 | */ 29 | events: Record = {}; 30 | 31 | /** 32 | * User supplied callbacks for events on this channel. 33 | */ 34 | private listeners: Record = {}; 35 | 36 | /** 37 | * Create a new class instance. 38 | */ 39 | constructor( 40 | socket: Socket, 41 | name: string, 42 | options: EchoOptionsWithDefaults, 43 | ) { 44 | super(); 45 | 46 | this.name = name; 47 | this.socket = socket; 48 | this.options = options; 49 | this.eventFormatter = new EventFormatter(this.options.namespace); 50 | 51 | this.subscribe(); 52 | } 53 | 54 | /** 55 | * Subscribe to a Socket.io channel. 56 | */ 57 | subscribe(): void { 58 | this.socket.emit("subscribe", { 59 | channel: this.name, 60 | auth: this.options.auth || {}, 61 | }); 62 | } 63 | 64 | /** 65 | * Unsubscribe from channel and ubind event callbacks. 66 | */ 67 | unsubscribe(): void { 68 | this.unbind(); 69 | 70 | this.socket.emit("unsubscribe", { 71 | channel: this.name, 72 | auth: this.options.auth || {}, 73 | }); 74 | } 75 | 76 | /** 77 | * Listen for an event on the channel instance. 78 | */ 79 | listen(event: string, callback: CallableFunction): this { 80 | this.on(this.eventFormatter.format(event), callback); 81 | 82 | return this; 83 | } 84 | 85 | /** 86 | * Stop listening for an event on the channel instance. 87 | */ 88 | stopListening(event: string, callback?: CallableFunction): this { 89 | this.unbindEvent(this.eventFormatter.format(event), callback); 90 | 91 | return this; 92 | } 93 | 94 | /** 95 | * Register a callback to be called anytime a subscription succeeds. 96 | */ 97 | subscribed(callback: CallableFunction): this { 98 | this.on("connect", (socket: Socket) => { 99 | callback(socket); 100 | }); 101 | 102 | return this; 103 | } 104 | 105 | /** 106 | * Register a callback to be called anytime an error occurs. 107 | */ 108 | error(_callback: CallableFunction): this { 109 | return this; 110 | } 111 | 112 | /** 113 | * Bind the channel's socket to an event and store the callback. 114 | */ 115 | on(event: string, callback: CallableFunction): this { 116 | this.listeners[event] = this.listeners[event] || []; 117 | 118 | if (!this.events[event]) { 119 | this.events[event] = (channel: string, data: unknown) => { 120 | if (this.name === channel && this.listeners[event]) { 121 | this.listeners[event].forEach((cb) => cb(data)); 122 | } 123 | }; 124 | 125 | this.socket.on(event, this.events[event]); 126 | } 127 | 128 | this.listeners[event].push(callback); 129 | 130 | return this; 131 | } 132 | 133 | /** 134 | * Unbind the channel's socket from all stored event callbacks. 135 | */ 136 | unbind(): void { 137 | Object.keys(this.events).forEach((event) => { 138 | this.unbindEvent(event); 139 | }); 140 | } 141 | 142 | /** 143 | * Unbind the listeners for the given event. 144 | */ 145 | protected unbindEvent(event: string, callback?: CallableFunction): void { 146 | this.listeners[event] = this.listeners[event] || []; 147 | 148 | if (callback) { 149 | this.listeners[event] = this.listeners[event].filter( 150 | (cb) => cb !== callback, 151 | ); 152 | } 153 | 154 | if (!callback || this.listeners[event].length === 0) { 155 | if (this.events[event]) { 156 | this.socket.removeListener(event, this.events[event]); 157 | 158 | delete this.events[event]; 159 | } 160 | 161 | delete this.listeners[event]; 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/channel/socketio-presence-channel.ts: -------------------------------------------------------------------------------- 1 | import type { 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 8 | extends SocketIoPrivateChannel 9 | implements PresenceChannel 10 | { 11 | /** 12 | * Register a callback to be called anytime the member list changes. 13 | */ 14 | here(callback: CallableFunction): this { 15 | this.on("presence:subscribed", (members: Record[]) => { 16 | callback(members.map((m) => m.user_info)); 17 | }); 18 | 19 | return this; 20 | } 21 | 22 | /** 23 | * Listen for someone joining the channel. 24 | */ 25 | joining(callback: CallableFunction): this { 26 | this.on("presence:joining", (member: Record) => 27 | callback(member.user_info), 28 | ); 29 | 30 | return this; 31 | } 32 | 33 | /** 34 | * Send a whisper event to other clients in the channel. 35 | */ 36 | whisper(eventName: string, data: unknown): this { 37 | this.socket.emit("client event", { 38 | channel: this.name, 39 | event: `client-${eventName}`, 40 | data: data, 41 | }); 42 | 43 | return this; 44 | } 45 | 46 | /** 47 | * Listen for someone leaving the channel. 48 | */ 49 | leaving(callback: CallableFunction): this { 50 | this.on("presence:leaving", (member: Record) => 51 | callback(member.user_info), 52 | ); 53 | 54 | return this; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/laravel-echo/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: unknown): this { 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 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/connector/connector.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { Channel, PresenceChannel } from "../channel"; 4 | import type { BroadcastDriver, EchoOptions } from "../echo"; 5 | 6 | export type EchoOptionsWithDefaults = { 7 | broadcaster: TBroadcaster; 8 | auth: { 9 | headers: Record; 10 | }; 11 | authEndpoint: string; 12 | userAuthentication: { 13 | endpoint: string; 14 | headers: Record; 15 | }; 16 | csrfToken: string | null; 17 | bearerToken: string | null; 18 | host: string | null; 19 | key: string | null; 20 | namespace: string | false; 21 | 22 | [key: string]: any; 23 | }; 24 | 25 | export abstract class Connector< 26 | TBroadcastDriver extends BroadcastDriver, 27 | TPublic extends Channel, 28 | TPrivate extends Channel, 29 | TPresence extends PresenceChannel, 30 | > { 31 | /** 32 | * Default connector options. 33 | */ 34 | public static readonly _defaultOptions = { 35 | auth: { 36 | headers: {}, 37 | }, 38 | authEndpoint: "/broadcasting/auth", 39 | userAuthentication: { 40 | endpoint: "/broadcasting/user-auth", 41 | headers: {}, 42 | }, 43 | csrfToken: null, 44 | bearerToken: null, 45 | host: null, 46 | key: null, 47 | namespace: "App.Events", 48 | } as const; 49 | 50 | /** 51 | * Connector options. 52 | */ 53 | options: EchoOptionsWithDefaults; 54 | 55 | /** 56 | * Create a new class instance. 57 | */ 58 | constructor(options: EchoOptions) { 59 | this.setOptions(options); 60 | this.connect(); 61 | } 62 | 63 | /** 64 | * Merge the custom options with the defaults. 65 | */ 66 | protected setOptions(options: EchoOptions): void { 67 | this.options = { 68 | ...Connector._defaultOptions, 69 | ...options, 70 | broadcaster: options.broadcaster as TBroadcastDriver, 71 | }; 72 | 73 | let token = this.csrfToken(); 74 | 75 | if (token) { 76 | this.options.auth.headers["X-CSRF-TOKEN"] = token; 77 | this.options.userAuthentication.headers["X-CSRF-TOKEN"] = token; 78 | } 79 | 80 | token = this.options.bearerToken; 81 | 82 | if (token) { 83 | this.options.auth.headers["Authorization"] = "Bearer " + token; 84 | this.options.userAuthentication.headers["Authorization"] = 85 | "Bearer " + token; 86 | } 87 | } 88 | 89 | /** 90 | * Extract the CSRF token from the page. 91 | */ 92 | protected csrfToken(): null | string { 93 | if (typeof window !== "undefined" && window.Laravel?.csrfToken) { 94 | return window.Laravel.csrfToken; 95 | } 96 | 97 | if (this.options.csrfToken) { 98 | return this.options.csrfToken; 99 | } 100 | 101 | if ( 102 | typeof document !== "undefined" && 103 | typeof document.querySelector === "function" 104 | ) { 105 | return ( 106 | document 107 | .querySelector('meta[name="csrf-token"]') 108 | ?.getAttribute("content") ?? null 109 | ); 110 | } 111 | 112 | return null; 113 | } 114 | 115 | /** 116 | * Create a fresh connection. 117 | */ 118 | abstract connect(): void; 119 | 120 | /** 121 | * Get a channel instance by name. 122 | */ 123 | abstract channel(channel: string): TPublic; 124 | 125 | /** 126 | * Get a private channel instance by name. 127 | */ 128 | abstract privateChannel(channel: string): TPrivate; 129 | 130 | /** 131 | * Get a presence channel instance by name. 132 | */ 133 | abstract presenceChannel(channel: string): TPresence; 134 | 135 | /** 136 | * Leave the given channel, as well as its private and presence variants. 137 | */ 138 | abstract leave(channel: string): void; 139 | 140 | /** 141 | * Leave the given channel. 142 | */ 143 | abstract leaveChannel(channel: string): void; 144 | 145 | /** 146 | * Get the socket_id of the connection. 147 | */ 148 | abstract socketId(): string | undefined; 149 | 150 | /** 151 | * Disconnect from the Echo server. 152 | */ 153 | abstract disconnect(): void; 154 | } 155 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/connector/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./connector"; 2 | export * from "./pusher-connector"; 3 | export * from "./socketio-connector"; 4 | export * from "./null-connector"; 5 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/connector/null-connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from "./connector"; 2 | import { 3 | NullChannel, 4 | NullPrivateChannel, 5 | NullPresenceChannel, 6 | NullEncryptedPrivateChannel, 7 | } from "../channel"; 8 | 9 | /** 10 | * This class creates a null connector. 11 | */ 12 | export class NullConnector extends Connector< 13 | "null", 14 | NullChannel, 15 | NullPrivateChannel, 16 | NullPresenceChannel 17 | > { 18 | /** 19 | * All of the subscribed channel names. 20 | */ 21 | channels: any = {}; 22 | 23 | /** 24 | * Create a fresh connection. 25 | */ 26 | connect(): void { 27 | // 28 | } 29 | 30 | /** 31 | * Listen for an event on a channel instance. 32 | */ 33 | listen( 34 | _name: string, 35 | _event: string, 36 | _callback: CallableFunction, 37 | ): NullChannel { 38 | return new NullChannel(); 39 | } 40 | 41 | /** 42 | * Get a channel instance by name. 43 | */ 44 | channel(_name: string): NullChannel { 45 | return new NullChannel(); 46 | } 47 | 48 | /** 49 | * Get a private channel instance by name. 50 | */ 51 | privateChannel(_name: string): NullPrivateChannel { 52 | return new NullPrivateChannel(); 53 | } 54 | 55 | /** 56 | * Get a private encrypted channel instance by name. 57 | */ 58 | encryptedPrivateChannel(_name: string): NullEncryptedPrivateChannel { 59 | return new NullEncryptedPrivateChannel(); 60 | } 61 | 62 | /** 63 | * Get a presence channel instance by name. 64 | */ 65 | presenceChannel(_name: string): NullPresenceChannel { 66 | return new NullPresenceChannel(); 67 | } 68 | 69 | /** 70 | * Leave the given channel, as well as its private and presence variants. 71 | */ 72 | leave(_name: string): void { 73 | // 74 | } 75 | 76 | /** 77 | * Leave the given channel. 78 | */ 79 | leaveChannel(_name: string): void { 80 | // 81 | } 82 | 83 | /** 84 | * Get the socket ID for the connection. 85 | */ 86 | socketId(): string { 87 | return "fake-socket-id"; 88 | } 89 | 90 | /** 91 | * Disconnect the connection. 92 | */ 93 | disconnect(): void { 94 | // 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/connector/pusher-connector.ts: -------------------------------------------------------------------------------- 1 | import type Pusher from "pusher-js"; 2 | import type { Options as PusherJsOptions } from "pusher-js"; 3 | import { 4 | PusherChannel, 5 | PusherEncryptedPrivateChannel, 6 | PusherPresenceChannel, 7 | PusherPrivateChannel, 8 | } from "../channel"; 9 | import type { BroadcastDriver } from "../echo"; 10 | import { Connector, type EchoOptionsWithDefaults } from "./connector"; 11 | 12 | type AnyPusherChannel = 13 | | PusherChannel 14 | | PusherPrivateChannel 15 | | PusherEncryptedPrivateChannel 16 | | PusherPresenceChannel; 17 | 18 | export type PusherOptions = 19 | EchoOptionsWithDefaults & { 20 | key: string; 21 | Pusher?: typeof Pusher; 22 | } & PusherJsOptions; 23 | 24 | /** 25 | * This class creates a connector to Pusher. 26 | */ 27 | export class PusherConnector< 28 | TBroadcastDriver extends BroadcastDriver, 29 | > extends Connector< 30 | TBroadcastDriver, 31 | PusherChannel, 32 | PusherPrivateChannel, 33 | PusherPresenceChannel 34 | > { 35 | /** 36 | * The Pusher instance. 37 | */ 38 | pusher: Pusher; 39 | 40 | /** 41 | * All of the subscribed channel names. 42 | */ 43 | channels: Record = {}; 44 | 45 | declare options: PusherOptions; 46 | 47 | /** 48 | * Create a fresh Pusher connection. 49 | */ 50 | connect(): void { 51 | if (typeof this.options.client !== "undefined") { 52 | this.pusher = this.options.client as Pusher; 53 | } else if (this.options.Pusher) { 54 | this.pusher = new this.options.Pusher( 55 | this.options.key, 56 | this.options, 57 | ); 58 | } else if ( 59 | typeof window !== "undefined" && 60 | typeof window.Pusher !== "undefined" 61 | ) { 62 | this.pusher = new window.Pusher(this.options.key, this.options); 63 | } else { 64 | throw new Error( 65 | "Pusher client not found. Should be globally available or passed via options.client", 66 | ); 67 | } 68 | } 69 | 70 | /** 71 | * Sign in the user via Pusher user authentication (https://pusher.com/docs/channels/using_channels/user-authentication/). 72 | */ 73 | signin(): void { 74 | this.pusher.signin(); 75 | } 76 | 77 | /** 78 | * Listen for an event on a channel instance. 79 | */ 80 | listen( 81 | name: string, 82 | event: string, 83 | callback: CallableFunction, 84 | ): AnyPusherChannel { 85 | return this.channel(name).listen(event, callback); 86 | } 87 | 88 | /** 89 | * Get a channel instance by name. 90 | */ 91 | channel(name: string): AnyPusherChannel { 92 | if (!this.channels[name]) { 93 | this.channels[name] = new PusherChannel( 94 | this.pusher, 95 | name, 96 | this.options, 97 | ); 98 | } 99 | 100 | return this.channels[name]; 101 | } 102 | 103 | /** 104 | * Get a private channel instance by name. 105 | */ 106 | privateChannel(name: string): PusherPrivateChannel { 107 | if (!this.channels["private-" + name]) { 108 | this.channels["private-" + name] = new PusherPrivateChannel( 109 | this.pusher, 110 | "private-" + name, 111 | this.options, 112 | ); 113 | } 114 | 115 | return this.channels[ 116 | "private-" + name 117 | ] as PusherPrivateChannel; 118 | } 119 | 120 | /** 121 | * Get a private encrypted channel instance by name. 122 | */ 123 | encryptedPrivateChannel( 124 | name: string, 125 | ): PusherEncryptedPrivateChannel { 126 | if (!this.channels["private-encrypted-" + name]) { 127 | this.channels["private-encrypted-" + name] = 128 | new PusherEncryptedPrivateChannel( 129 | this.pusher, 130 | "private-encrypted-" + name, 131 | this.options, 132 | ); 133 | } 134 | 135 | return this.channels[ 136 | "private-encrypted-" + name 137 | ] as PusherEncryptedPrivateChannel; 138 | } 139 | 140 | /** 141 | * Get a presence channel instance by name. 142 | */ 143 | presenceChannel(name: string): PusherPresenceChannel { 144 | if (!this.channels["presence-" + name]) { 145 | this.channels["presence-" + name] = new PusherPresenceChannel( 146 | this.pusher, 147 | "presence-" + name, 148 | this.options, 149 | ); 150 | } 151 | 152 | return this.channels[ 153 | "presence-" + name 154 | ] as PusherPresenceChannel; 155 | } 156 | 157 | /** 158 | * Leave the given channel, as well as its private and presence variants. 159 | */ 160 | leave(name: string): void { 161 | let channels = [ 162 | name, 163 | "private-" + name, 164 | "private-encrypted-" + name, 165 | "presence-" + name, 166 | ]; 167 | 168 | channels.forEach((name: string) => { 169 | this.leaveChannel(name); 170 | }); 171 | } 172 | 173 | /** 174 | * Leave the given channel. 175 | */ 176 | leaveChannel(name: string): void { 177 | if (this.channels[name]) { 178 | this.channels[name].unsubscribe(); 179 | 180 | delete this.channels[name]; 181 | } 182 | } 183 | 184 | /** 185 | * Get the socket ID for the connection. 186 | */ 187 | socketId(): string { 188 | return this.pusher.connection.socket_id; 189 | } 190 | 191 | /** 192 | * Disconnect Pusher connection. 193 | */ 194 | disconnect(): void { 195 | this.pusher.disconnect(); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/connector/socketio-connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from "./connector"; 2 | import { 3 | SocketIoChannel, 4 | SocketIoPrivateChannel, 5 | SocketIoPresenceChannel, 6 | } from "../channel"; 7 | import type { 8 | io, 9 | ManagerOptions, 10 | Socket, 11 | SocketOptions, 12 | } from "socket.io-client"; 13 | 14 | type AnySocketIoChannel = 15 | | SocketIoChannel 16 | | SocketIoPrivateChannel 17 | | SocketIoPresenceChannel; 18 | 19 | /** 20 | * This class creates a connector to a Socket.io server. 21 | */ 22 | export class SocketIoConnector extends Connector< 23 | "socket.io", 24 | SocketIoChannel, 25 | SocketIoPrivateChannel, 26 | SocketIoPresenceChannel 27 | > { 28 | /** 29 | * The Socket.io connection instance. 30 | */ 31 | socket: Socket; 32 | 33 | /** 34 | * All of the subscribed channel names. 35 | */ 36 | channels: { [name: string]: SocketIoChannel } = {}; 37 | 38 | /** 39 | * Create a fresh Socket.io connection. 40 | */ 41 | connect(): void { 42 | let io = this.getSocketIO(); 43 | 44 | this.socket = io( 45 | this.options.host ?? undefined, 46 | this.options as Partial, 47 | ); 48 | 49 | this.socket.on("reconnect", () => { 50 | Object.values(this.channels).forEach((channel) => { 51 | channel.subscribe(); 52 | }); 53 | }); 54 | } 55 | 56 | /** 57 | * Get socket.io module from global scope or options. 58 | */ 59 | getSocketIO(): typeof io { 60 | if (typeof this.options.client !== "undefined") { 61 | return this.options.client as typeof io; 62 | } 63 | 64 | if (typeof window !== "undefined" && typeof window.io !== "undefined") { 65 | return window.io; 66 | } 67 | 68 | throw new Error( 69 | "Socket.io client not found. Should be globally available or passed via options.client", 70 | ); 71 | } 72 | 73 | /** 74 | * Listen for an event on a channel instance. 75 | */ 76 | listen( 77 | name: string, 78 | event: string, 79 | callback: CallableFunction, 80 | ): AnySocketIoChannel { 81 | return this.channel(name).listen(event, callback); 82 | } 83 | 84 | /** 85 | * Get a channel instance by name. 86 | */ 87 | channel(name: string): AnySocketIoChannel { 88 | if (!this.channels[name]) { 89 | this.channels[name] = new SocketIoChannel( 90 | this.socket, 91 | name, 92 | this.options, 93 | ); 94 | } 95 | 96 | return this.channels[name]; 97 | } 98 | 99 | /** 100 | * Get a private channel instance by name. 101 | */ 102 | privateChannel(name: string): SocketIoPrivateChannel { 103 | if (!this.channels["private-" + name]) { 104 | this.channels["private-" + name] = new SocketIoPrivateChannel( 105 | this.socket, 106 | "private-" + name, 107 | this.options, 108 | ); 109 | } 110 | 111 | return this.channels["private-" + name] as SocketIoPrivateChannel; 112 | } 113 | 114 | /** 115 | * Get a presence channel instance by name. 116 | */ 117 | presenceChannel(name: string): SocketIoPresenceChannel { 118 | if (!this.channels["presence-" + name]) { 119 | this.channels["presence-" + name] = new SocketIoPresenceChannel( 120 | this.socket, 121 | "presence-" + name, 122 | this.options, 123 | ); 124 | } 125 | 126 | return this.channels["presence-" + name] as SocketIoPresenceChannel; 127 | } 128 | 129 | /** 130 | * Leave the given channel, as well as its private and presence variants. 131 | */ 132 | leave(name: string): void { 133 | let channels = [name, "private-" + name, "presence-" + name]; 134 | 135 | channels.forEach((name) => { 136 | this.leaveChannel(name); 137 | }); 138 | } 139 | 140 | /** 141 | * Leave the given channel. 142 | */ 143 | leaveChannel(name: string): void { 144 | if (this.channels[name]) { 145 | this.channels[name].unsubscribe(); 146 | 147 | delete this.channels[name]; 148 | } 149 | } 150 | 151 | /** 152 | * Get the socket ID for the connection. 153 | */ 154 | socketId(): string | undefined { 155 | return this.socket.id; 156 | } 157 | 158 | /** 159 | * Disconnect Socketio connection. 160 | */ 161 | disconnect(): void { 162 | this.socket.disconnect(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/echo.ts: -------------------------------------------------------------------------------- 1 | import type { InternalAxiosRequestConfig } from "axios"; 2 | import { 3 | Channel, 4 | NullChannel, 5 | NullEncryptedPrivateChannel, 6 | NullPresenceChannel, 7 | NullPrivateChannel, 8 | PusherChannel, 9 | PusherEncryptedPrivateChannel, 10 | PusherPresenceChannel, 11 | PusherPrivateChannel, 12 | SocketIoChannel, 13 | SocketIoPresenceChannel, 14 | SocketIoPrivateChannel, 15 | type PresenceChannel, 16 | } from "./channel"; 17 | import { 18 | Connector, 19 | NullConnector, 20 | PusherConnector, 21 | SocketIoConnector, 22 | type PusherOptions, 23 | } from "./connector"; 24 | import { isConstructor } from "./util"; 25 | 26 | /** 27 | * This class is the primary API for interacting with broadcasting. 28 | */ 29 | export default class Echo { 30 | /** 31 | * The broadcasting connector. 32 | */ 33 | connector: Broadcaster[Exclude]["connector"]; 34 | 35 | /** 36 | * The Echo options. 37 | */ 38 | options: EchoOptions; 39 | 40 | /** 41 | * Create a new class instance. 42 | */ 43 | constructor(options: EchoOptions) { 44 | this.options = options; 45 | this.connect(); 46 | 47 | if (!this.options.withoutInterceptors) { 48 | this.registerInterceptors(); 49 | } 50 | } 51 | 52 | /** 53 | * Get a channel instance by name. 54 | */ 55 | channel(channel: string): Broadcaster[T]["public"] { 56 | return this.connector.channel(channel); 57 | } 58 | 59 | /** 60 | * Create a new connection. 61 | */ 62 | connect(): void { 63 | if (this.options.broadcaster === "reverb") { 64 | this.connector = new PusherConnector<"reverb">({ 65 | ...this.options, 66 | cluster: "", 67 | }); 68 | } else if (this.options.broadcaster === "pusher") { 69 | this.connector = new PusherConnector<"pusher">(this.options); 70 | } else if (this.options.broadcaster === "ably") { 71 | this.connector = new PusherConnector<"pusher">({ 72 | ...this.options, 73 | cluster: "", 74 | broadcaster: "pusher", 75 | }); 76 | } else if (this.options.broadcaster === "socket.io") { 77 | this.connector = new SocketIoConnector(this.options); 78 | } else if (this.options.broadcaster === "null") { 79 | this.connector = new NullConnector(this.options); 80 | } else if ( 81 | typeof this.options.broadcaster === "function" && 82 | isConstructor(this.options.broadcaster) 83 | ) { 84 | this.connector = new this.options.broadcaster(this.options); 85 | } else { 86 | throw new Error( 87 | `Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} is not supported.`, 88 | ); 89 | } 90 | } 91 | 92 | /** 93 | * Disconnect from the Echo server. 94 | */ 95 | disconnect(): void { 96 | this.connector.disconnect(); 97 | } 98 | 99 | /** 100 | * Get a presence channel instance by name. 101 | */ 102 | join(channel: string): Broadcaster[T]["presence"] { 103 | return this.connector.presenceChannel(channel); 104 | } 105 | 106 | /** 107 | * Leave the given channel, as well as its private and presence variants. 108 | */ 109 | leave(channel: string): void { 110 | this.connector.leave(channel); 111 | } 112 | 113 | /** 114 | * Leave the given channel. 115 | */ 116 | leaveChannel(channel: string): void { 117 | this.connector.leaveChannel(channel); 118 | } 119 | 120 | /** 121 | * Leave all channels. 122 | */ 123 | leaveAllChannels(): void { 124 | for (const channel in this.connector.channels) { 125 | this.leaveChannel(channel); 126 | } 127 | } 128 | 129 | /** 130 | * Listen for an event on a channel instance. 131 | */ 132 | listen( 133 | channel: string, 134 | event: string, 135 | callback: CallableFunction, 136 | ): Broadcaster[T]["public"] { 137 | return this.connector.listen(channel, event, callback); 138 | } 139 | 140 | /** 141 | * Get a private channel instance by name. 142 | */ 143 | private(channel: string): Broadcaster[T]["private"] { 144 | return this.connector.privateChannel(channel); 145 | } 146 | 147 | /** 148 | * Get a private encrypted channel instance by name. 149 | */ 150 | encryptedPrivate(channel: string): Broadcaster[T]["encrypted"] { 151 | if (this.connectorSupportsEncryptedPrivateChannels(this.connector)) { 152 | return this.connector.encryptedPrivateChannel(channel); 153 | } 154 | 155 | throw new Error( 156 | `Broadcaster ${typeof this.options.broadcaster} ${String( 157 | this.options.broadcaster, 158 | )} does not support encrypted private channels.`, 159 | ); 160 | } 161 | 162 | private connectorSupportsEncryptedPrivateChannels( 163 | connector: unknown, 164 | ): connector is PusherConnector | NullConnector { 165 | return ( 166 | connector instanceof PusherConnector || 167 | connector instanceof NullConnector 168 | ); 169 | } 170 | 171 | /** 172 | * Get the Socket ID for the connection. 173 | */ 174 | socketId(): string | undefined { 175 | return this.connector.socketId(); 176 | } 177 | 178 | /** 179 | * Register 3rd party request interceptiors. These are used to automatically 180 | * send a connections socket id to a Laravel app with a X-Socket-Id header. 181 | */ 182 | registerInterceptors(): void { 183 | // TODO: This package is deprecated and we should remove it in a future version. 184 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 185 | if (typeof Vue !== "undefined" && Vue?.http) { 186 | this.registerVueRequestInterceptor(); 187 | } 188 | 189 | if (typeof axios === "function") { 190 | this.registerAxiosRequestInterceptor(); 191 | } 192 | 193 | if (typeof jQuery === "function") { 194 | this.registerjQueryAjaxSetup(); 195 | } 196 | 197 | if (typeof Turbo === "object") { 198 | this.registerTurboRequestInterceptor(); 199 | } 200 | } 201 | 202 | /** 203 | * Register a Vue HTTP interceptor to add the X-Socket-ID header. 204 | */ 205 | registerVueRequestInterceptor(): void { 206 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 207 | Vue.http.interceptors.push( 208 | (request: Record, next: CallableFunction) => { 209 | if (this.socketId()) { 210 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 211 | request.headers.set("X-Socket-ID", this.socketId()); 212 | } 213 | 214 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 215 | next(); 216 | }, 217 | ); 218 | } 219 | 220 | /** 221 | * Register an Axios HTTP interceptor to add the X-Socket-ID header. 222 | */ 223 | registerAxiosRequestInterceptor(): void { 224 | axios!.interceptors.request.use( 225 | (config: InternalAxiosRequestConfig) => { 226 | if (this.socketId()) { 227 | config.headers["X-Socket-Id"] = this.socketId(); 228 | } 229 | 230 | return config; 231 | }, 232 | ); 233 | } 234 | 235 | /** 236 | * Register jQuery AjaxPrefilter to add the X-Socket-ID header. 237 | */ 238 | registerjQueryAjaxSetup(): void { 239 | if (typeof jQuery.ajax != "undefined") { 240 | jQuery.ajaxPrefilter( 241 | ( 242 | _options: any, 243 | _originalOptions: any, 244 | xhr: Record, 245 | ) => { 246 | if (this.socketId()) { 247 | xhr.setRequestHeader("X-Socket-Id", this.socketId()); 248 | } 249 | }, 250 | ); 251 | } 252 | } 253 | 254 | /** 255 | * Register the Turbo Request interceptor to add the X-Socket-ID header. 256 | */ 257 | registerTurboRequestInterceptor(): void { 258 | document.addEventListener( 259 | "turbo:before-fetch-request", 260 | (event: Record) => { 261 | event.detail.fetchOptions.headers["X-Socket-Id"] = 262 | this.socketId(); 263 | }, 264 | ); 265 | } 266 | } 267 | 268 | /** 269 | * Export channel classes for TypeScript. 270 | */ 271 | export { Channel, Connector, type PresenceChannel }; 272 | 273 | export { EventFormatter } from "./util"; 274 | 275 | type CustomOmit = { 276 | [P in keyof T as Exclude]: T[P]; 277 | }; 278 | 279 | /** 280 | * Specifies the broadcaster 281 | */ 282 | export type Broadcaster = { 283 | reverb: { 284 | connector: PusherConnector<"reverb">; 285 | public: PusherChannel<"reverb">; 286 | private: PusherPrivateChannel<"reverb">; 287 | encrypted: PusherEncryptedPrivateChannel<"reverb">; 288 | presence: PusherPresenceChannel<"reverb">; 289 | options: GenericOptions<"reverb"> & 290 | Partial, "cluster">>; 291 | }; 292 | pusher: { 293 | connector: PusherConnector<"pusher">; 294 | public: PusherChannel<"pusher">; 295 | private: PusherPrivateChannel<"pusher">; 296 | encrypted: PusherEncryptedPrivateChannel<"pusher">; 297 | presence: PusherPresenceChannel<"pusher">; 298 | options: GenericOptions<"pusher"> & Partial>; 299 | }; 300 | ably: { 301 | connector: PusherConnector<"pusher">; 302 | public: PusherChannel<"pusher">; 303 | private: PusherPrivateChannel<"pusher">; 304 | encrypted: PusherEncryptedPrivateChannel<"pusher">; 305 | presence: PusherPresenceChannel<"pusher">; 306 | options: GenericOptions<"ably"> & Partial>; 307 | }; 308 | "socket.io": { 309 | connector: SocketIoConnector; 310 | public: SocketIoChannel; 311 | private: SocketIoPrivateChannel; 312 | encrypted: never; 313 | presence: SocketIoPresenceChannel; 314 | options: GenericOptions<"socket.io">; 315 | }; 316 | null: { 317 | connector: NullConnector; 318 | public: NullChannel; 319 | private: NullPrivateChannel; 320 | encrypted: NullEncryptedPrivateChannel; 321 | presence: NullPresenceChannel; 322 | options: GenericOptions<"null">; 323 | }; 324 | function: { 325 | connector: any; 326 | public: any; 327 | private: any; 328 | encrypted: any; 329 | presence: any; 330 | options: GenericOptions<"function">; 331 | }; 332 | }; 333 | 334 | type Constructor = new (...args: any[]) => T; 335 | 336 | export type BroadcastDriver = Exclude; 337 | 338 | type GenericOptions = { 339 | /** 340 | * The broadcast connector. 341 | */ 342 | broadcaster: TBroadcaster extends "function" 343 | ? Constructor> 344 | : TBroadcaster; 345 | 346 | auth?: { 347 | headers: Record; 348 | }; 349 | authEndpoint?: string; 350 | userAuthentication?: { 351 | endpoint: string; 352 | headers: Record; 353 | }; 354 | csrfToken?: string | null; 355 | bearerToken?: string | null; 356 | host?: string | null; 357 | key?: string | null; 358 | namespace?: string | false; 359 | withoutInterceptors?: boolean; 360 | 361 | [key: string]: any; 362 | }; 363 | 364 | export type EchoOptions = 365 | Broadcaster[TBroadcaster]["options"]; 366 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/index.iife.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./echo"; 2 | -------------------------------------------------------------------------------- /packages/laravel-echo/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 | undefined) { 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 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/util/index.ts: -------------------------------------------------------------------------------- 1 | function isConstructor(obj: unknown): obj is new (...args: any[]) => any { 2 | try { 3 | new (obj as new (...args: any[]) => any)(); 4 | } catch (err) { 5 | if ( 6 | err instanceof Error && 7 | err.message.includes("is not a constructor") 8 | ) { 9 | return false; 10 | } 11 | } 12 | 13 | return true; 14 | } 15 | 16 | export { isConstructor }; 17 | export * from "./event-formatter"; 18 | -------------------------------------------------------------------------------- /packages/laravel-echo/tests/channel/socketio-channel.test.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client"; 2 | import { beforeEach, describe, expect, test, vi } from "vitest"; 3 | import { SocketIoChannel } from "../../src/channel"; 4 | import { Connector } from "../../src/connector"; 5 | 6 | describe("SocketIoChannel", () => { 7 | let channel: SocketIoChannel; 8 | let socket: Socket; 9 | 10 | beforeEach(() => { 11 | const channelName = "some.channel"; 12 | let listeners: any[] = []; 13 | socket = { 14 | emit: (event: any, data: unknown) => { 15 | listeners 16 | .filter(([e]) => e === event) 17 | .forEach(([, fn]) => fn(channelName, data)); 18 | }, 19 | on: (event: any, fn): any => listeners.push([event, fn]), 20 | removeListener: (event: any, fn: any) => { 21 | listeners = listeners.filter(([e, f]) => 22 | !fn ? e !== event : e !== event || f !== fn, 23 | ); 24 | }, 25 | } as Socket; 26 | 27 | channel = new SocketIoChannel(socket, channelName, { 28 | broadcaster: "socket.io", 29 | ...Connector._defaultOptions, 30 | namespace: false, 31 | }); 32 | }); 33 | 34 | test("triggers all listeners for an event", () => { 35 | const l1 = vi.fn(); 36 | const l2 = vi.fn(); 37 | const l3 = vi.fn(); 38 | channel.listen("MyEvent", l1); 39 | channel.listen("MyEvent", l2); 40 | channel.listen("MyOtherEvent", l3); 41 | 42 | socket.emit("MyEvent", {}); 43 | 44 | expect(l1).toHaveBeenCalled(); 45 | expect(l2).toHaveBeenCalled(); 46 | expect(l3).not.toHaveBeenCalled(); 47 | 48 | socket.emit("MyOtherEvent", {}); 49 | 50 | expect(l3).toHaveBeenCalled(); 51 | }); 52 | 53 | test("can remove a listener for an event", () => { 54 | const l1 = vi.fn(); 55 | const l2 = vi.fn(); 56 | const l3 = vi.fn(); 57 | channel.listen("MyEvent", l1); 58 | channel.listen("MyEvent", l2); 59 | channel.listen("MyOtherEvent", l3); 60 | 61 | channel.stopListening("MyEvent", l1); 62 | 63 | socket.emit("MyEvent", {}); 64 | 65 | expect(l1).not.toHaveBeenCalled(); 66 | expect(l2).toHaveBeenCalled(); 67 | expect(l3).not.toHaveBeenCalled(); 68 | 69 | socket.emit("MyOtherEvent", {}); 70 | 71 | expect(l3).toHaveBeenCalled(); 72 | }); 73 | 74 | test("can remove all listeners for an event", () => { 75 | const l1 = vi.fn(); 76 | const l2 = vi.fn(); 77 | const l3 = vi.fn(); 78 | channel.listen("MyEvent", l1); 79 | channel.listen("MyEvent", l2); 80 | channel.listen("MyOtherEvent", l3); 81 | 82 | channel.stopListening("MyEvent"); 83 | 84 | socket.emit("MyEvent", {}); 85 | 86 | expect(l1).not.toHaveBeenCalled(); 87 | expect(l2).not.toHaveBeenCalled(); 88 | expect(l3).not.toHaveBeenCalled(); 89 | 90 | socket.emit("MyOtherEvent", {}); 91 | 92 | expect(l3).toHaveBeenCalled(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /packages/laravel-echo/tests/echo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { NullConnector } from "../src/connector"; 3 | import Echo from "../src/echo"; 4 | 5 | describe("Echo", () => { 6 | test("it will not throw error for supported driver", () => { 7 | expect( 8 | () => 9 | new Echo({ broadcaster: "reverb", withoutInterceptors: true }), 10 | ).not.toThrow("Broadcaster string reverb is not supported."); 11 | 12 | expect( 13 | () => 14 | new Echo({ broadcaster: "pusher", withoutInterceptors: true }), 15 | ).not.toThrow("Broadcaster string pusher is not supported."); 16 | 17 | expect( 18 | () => 19 | new Echo({ 20 | broadcaster: "socket.io", 21 | withoutInterceptors: true, 22 | }), 23 | ).not.toThrow("Broadcaster string socket.io is not supported."); 24 | 25 | expect( 26 | () => new Echo({ broadcaster: "null", withoutInterceptors: true }), 27 | ).not.toThrow("Broadcaster string null is not supported."); 28 | expect( 29 | () => 30 | new Echo({ 31 | broadcaster: NullConnector, 32 | withoutInterceptors: true, 33 | }), 34 | ).not.toThrow(); 35 | expect( 36 | () => 37 | // @ts-ignore 38 | // eslint-disable-next-line @typescript-eslint/no-empty-function 39 | new Echo({ broadcaster: () => {}, withoutInterceptors: true }), 40 | ).not.toThrow("Broadcaster function is not supported."); 41 | }); 42 | 43 | test("it will throw error for unsupported driver", () => { 44 | expect( 45 | // @ts-ignore 46 | // eslint-disable-next-line 47 | () => new Echo({ broadcaster: "foo", withoutInterceptors: true }), 48 | ).toThrow("Broadcaster string foo is not supported."); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/laravel-echo/tests/util/event-formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test } from "vitest"; 2 | import { EventFormatter } from "../../src/util"; 3 | 4 | describe("EventFormatter", () => { 5 | let eventFormatter: EventFormatter; 6 | 7 | beforeEach(() => { 8 | eventFormatter = new EventFormatter("App.Events"); 9 | }); 10 | 11 | test("prepends an event with a namespace and replaces dot separators with backslashes", () => { 12 | let formatted = eventFormatter.format("Users.UserCreated"); 13 | 14 | expect(formatted).toBe("App\\Events\\Users\\UserCreated"); 15 | }); 16 | 17 | test("does not prepend a namespace when an event starts with a dot", () => { 18 | let formatted = eventFormatter.format(".App\\Users\\UserCreated"); 19 | 20 | expect(formatted).toBe("App\\Users\\UserCreated"); 21 | }); 22 | 23 | test("does not prepend a namespace when an event starts with a backslash", () => { 24 | let formatted = eventFormatter.format("\\App\\Users\\UserCreated"); 25 | 26 | expect(formatted).toBe("App\\Users\\UserCreated"); 27 | }); 28 | 29 | test("does not replace dot separators when the event starts with a dot", () => { 30 | let formatted = eventFormatter.format(".users.created"); 31 | 32 | expect(formatted).toBe("users.created"); 33 | }); 34 | 35 | test("does not replace dot separators when the event starts with a backslash", () => { 36 | let formatted = eventFormatter.format("\\users.created"); 37 | 38 | expect(formatted).toBe("users.created"); 39 | }); 40 | 41 | test("does not prepend a namespace when none is set", () => { 42 | let eventFormatter = new EventFormatter(false); 43 | 44 | let formatted = eventFormatter.format("Users.UserCreated"); 45 | 46 | expect(formatted).toBe("Users\\UserCreated"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/laravel-echo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./dist", 5 | "emitDeclarationOnly": true, 6 | "module": "ES2020", 7 | "moduleResolution": "bundler", 8 | "outDir": "./dist", 9 | "sourceMap": false, 10 | "target": "ES2020", 11 | "verbatimModuleSyntax": true, 12 | "skipLibCheck": true, 13 | "importHelpers": true, 14 | "strictPropertyInitialization": false, 15 | "allowSyntheticDefaultImports": true, 16 | "isolatedModules": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "esModuleInterop": true, 20 | "strict": true, 21 | "typeRoots": ["node_modules/@types", "./typings"], 22 | "lib": ["dom", "es2020"] 23 | }, 24 | "include": ["./typings/**/*.ts", "./src/**/*.ts"], 25 | "exclude": ["./node_modules", "./tests/**/*.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/laravel-echo/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare let Vue: any; 2 | declare let axios: any; 3 | declare let jQuery: any; 4 | declare let Turbo: any; 5 | -------------------------------------------------------------------------------- /packages/laravel-echo/typings/window.d.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosStatic } from "axios"; 2 | import type { JQueryStatic } from "jquery"; 3 | import type Pusher from "pusher-js"; 4 | import type { io } from "socket.io-client"; 5 | 6 | declare global { 7 | interface Window { 8 | Laravel?: { 9 | csrfToken?: string; 10 | }; 11 | 12 | io?: typeof io; 13 | Pusher?: typeof Pusher; 14 | 15 | Vue?: any; 16 | axios?: AxiosStatic; 17 | jQuery?: JQueryStatic; 18 | Turbo?: object; 19 | } 20 | 21 | const Vue: any | undefined; 22 | const axios: AxiosStatic | undefined; 23 | const jQuery: JQueryStatic | undefined; 24 | const Turbo: object | undefined; 25 | } 26 | -------------------------------------------------------------------------------- /packages/laravel-echo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig, UserConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | const config: UserConfig = (() => { 6 | const common: Partial = { 7 | rollupOptions: { 8 | external: ["pusher-js", "socket.io-client"], 9 | output: { 10 | globals: { 11 | "pusher-js": "Pusher", 12 | "socket.io-client": "io", 13 | }, 14 | }, 15 | }, 16 | outDir: resolve(__dirname, "dist"), 17 | sourcemap: true, 18 | minify: true, 19 | }; 20 | 21 | if (process.env.FORMAT === "iife") { 22 | return { 23 | build: { 24 | lib: { 25 | entry: resolve(__dirname, "src/echo.ts"), 26 | name: "Echo", 27 | formats: ["iife"], 28 | fileName: () => "echo.iife.js", 29 | }, 30 | ...common, 31 | emptyOutDir: false, // Don't empty the output directory for the second build 32 | }, 33 | }; 34 | } 35 | 36 | return { 37 | plugins: [ 38 | dts({ 39 | insertTypesEntry: true, 40 | rollupTypes: true, 41 | include: ["src/**/*.ts"], 42 | }), 43 | ], 44 | build: { 45 | lib: { 46 | entry: resolve(__dirname, "src/echo.ts"), 47 | formats: ["es", "cjs"], 48 | fileName: (format, entryName) => { 49 | return `${entryName}.${format === "es" ? "js" : "common.js"}`; 50 | }, 51 | }, 52 | emptyOutDir: true, 53 | ...common, 54 | }, 55 | test: { 56 | globals: true, 57 | environment: "jsdom", 58 | }, 59 | }; 60 | })(); 61 | 62 | export default defineConfig(config); 63 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Echo React Helpers 2 | 3 | ## `configureEcho` 4 | 5 | You must call this function somewhere in your app _before_ you use `useEcho` in a component to configure your Echo instance. You only need to pass the required data: 6 | 7 | ```ts 8 | import { configureEcho } from "@laravel/echo-react"; 9 | 10 | configureEcho({ 11 | broadcaster: "reverb", 12 | }); 13 | ``` 14 | 15 | Based on your brodcaster, the package will fill in appropriate defaults for the rest of the config [based on the Echo documentation](https://laravel.com/docs/broadcasting#client-side-installation). You can always override these values by simply passing in your own. 16 | 17 | In the above example, the configuration would also fill in the following keys if they aren't present: 18 | 19 | ```ts 20 | { 21 | key: import.meta.env.VITE_REVERB_APP_KEY, 22 | wsHost: import.meta.env.VITE_REVERB_HOST, 23 | wsPort: import.meta.env.VITE_REVERB_PORT, 24 | wssPort: import.meta.env.VITE_REVERB_PORT, 25 | forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', 26 | enabledTransports: ['ws', 'wss'], 27 | } 28 | ``` 29 | 30 | ## `useEcho` Hook 31 | 32 | Connect to private channel: 33 | 34 | ```ts 35 | import { useEcho } from "@laravel/echo-react"; 36 | 37 | const { leaveChannel, leave, stopListening, listen } = useEcho( 38 | `orders.${orderId}`, 39 | "OrderShipmentStatusUpdated", 40 | (e) => { 41 | console.log(e.order); 42 | }, 43 | ); 44 | 45 | // Stop listening without leaving channel 46 | stopListening(); 47 | 48 | // Start listening again 49 | listen(); 50 | 51 | // Leave channel 52 | leaveChannel(); 53 | 54 | // Leave a channel and also its associated private and presence channels 55 | leave(); 56 | ``` 57 | 58 | Multiple events: 59 | 60 | ```ts 61 | useEcho( 62 | `orders.${orderId}`, 63 | ["OrderShipmentStatusUpdated", "OrderShipped"], 64 | (e) => { 65 | console.log(e.order); 66 | }, 67 | ); 68 | ``` 69 | 70 | Specify shape of payload data: 71 | 72 | ```ts 73 | type OrderData = { 74 | order: { 75 | id: number; 76 | user: { 77 | id: number; 78 | name: string; 79 | }; 80 | created_at: string; 81 | }; 82 | }; 83 | 84 | useEcho(`orders.${orderId}`, "OrderShipmentStatusUpdated", (e) => { 85 | console.log(e.order.id); 86 | console.log(e.order.user.id); 87 | }); 88 | ``` 89 | 90 | Connect to public channel: 91 | 92 | ```ts 93 | useEchoPublic("posts", "PostPublished", (e) => { 94 | console.log(e.post); 95 | }); 96 | ``` 97 | 98 | Connect to presence channel: 99 | 100 | ```ts 101 | useEchoPresence("posts", "PostPublished", (e) => { 102 | console.log(e.post); 103 | }); 104 | ``` 105 | 106 | Listening for model events: 107 | 108 | ```ts 109 | useEchoModel("App.Models.User", userId, ["UserCreated", "UserUpdated"], (e) => { 110 | console.log(e.model); 111 | }); 112 | ``` 113 | -------------------------------------------------------------------------------- /packages/react/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | 4 | const config = [ 5 | { 6 | ignores: ["dist/**/*"], 7 | files: ["src/**/*.ts"], 8 | languageOptions: { 9 | parser: tsParser, // Use the imported parser object 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | project: "./tsconfig.json", // Path to your TypeScript configuration file 14 | }, 15 | }, 16 | plugins: { 17 | "@typescript-eslint": tsPlugin, 18 | }, 19 | rules: { 20 | ...tsPlugin.configs.recommended.rules, 21 | ...tsPlugin.configs["recommended-requiring-type-checking"].rules, 22 | "@typescript-eslint/ban-types": "off", 23 | "@typescript-eslint/no-empty-object-type": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/no-floating-promises": "error", 26 | "@typescript-eslint/no-unsafe-argument": "warn", 27 | "@typescript-eslint/no-unsafe-assignment": "warn", 28 | "@typescript-eslint/no-unsafe-call": "warn", 29 | "@typescript-eslint/no-unsafe-function-type": "off", 30 | "@typescript-eslint/no-unsafe-member-access": "warn", 31 | "@typescript-eslint/no-unsafe-return": "warn", 32 | "@typescript-eslint/no-unused-vars": [ 33 | "warn", 34 | { argsIgnorePattern: "^_" }, 35 | ], 36 | "no-console": "warn", 37 | "prefer-const": "off", 38 | }, 39 | }, 40 | ]; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laravel/echo-react", 3 | "version": "2.1.5", 4 | "description": "React hooks for seamless integration with Laravel Echo.", 5 | "keywords": [ 6 | "laravel", 7 | "pusher", 8 | "ably", 9 | "react" 10 | ], 11 | "homepage": "https://github.com/laravel/echo/tree/2.x/packages/react", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/laravel/echo" 15 | }, 16 | "license": "MIT", 17 | "author": { 18 | "name": "Taylor Otwell" 19 | }, 20 | "type": "module", 21 | "main": "dist/index.common.js", 22 | "module": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "scripts": { 25 | "build": "vite build && FORMAT=iife vite build", 26 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 27 | "prepublish": "pnpm run build", 28 | "release": "vitest --run && git push --follow-tags && pnpm publish", 29 | "test": "vitest", 30 | "format": "prettier --write ." 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.26.7", 34 | "@babel/plugin-proposal-decorators": "^7.25.9", 35 | "@babel/plugin-proposal-function-sent": "^7.25.9", 36 | "@babel/plugin-proposal-throw-expressions": "^7.25.9", 37 | "@babel/plugin-transform-export-namespace-from": "^7.25.9", 38 | "@babel/plugin-transform-numeric-separator": "^7.25.9", 39 | "@babel/plugin-transform-object-assign": "^7.25.9", 40 | "@babel/preset-env": "^7.26.7", 41 | "@testing-library/dom": "^10.4.0", 42 | "@testing-library/react": "^14.3.1", 43 | "@testing-library/react-hooks": "^8.0.1", 44 | "@types/node": "^20.0.0", 45 | "@types/react": "^19.1.2", 46 | "@types/react-dom": "^19.1.2", 47 | "@typescript-eslint/eslint-plugin": "^8.21.0", 48 | "@typescript-eslint/parser": "^8.21.0", 49 | "eslint": "^9.0.0", 50 | "jsdom": "^26.1.0", 51 | "laravel-echo": "workspace:^", 52 | "prettier": "^3.5.3", 53 | "pusher-js": "^8.0", 54 | "react": "^19.1.0", 55 | "react-dom": "^19.1.0", 56 | "socket.io-client": "^4.0", 57 | "tslib": "^2.8.1", 58 | "typescript": "^5.7.0", 59 | "vite": "^5.1.0", 60 | "vite-plugin-dts": "^3.7.0", 61 | "vitest": "^3.1.2" 62 | }, 63 | "peerDependencies": { 64 | "pusher-js": "*", 65 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 66 | "socket.io-client": "*" 67 | }, 68 | "typesVersions": { 69 | "*": { 70 | "socket.io-client": [], 71 | "pusher-js": [] 72 | } 73 | }, 74 | "engines": { 75 | "node": ">=20" 76 | }, 77 | "exports": { 78 | ".": { 79 | "types": "./dist/index.d.ts", 80 | "import": "./dist/index.js", 81 | "require": "./dist/index.common.js" 82 | } 83 | }, 84 | "overrides": { 85 | "glob": "^9.0.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/react/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import Echo, { type BroadcastDriver, type EchoOptions } from "laravel-echo"; 2 | import Pusher from "pusher-js"; 3 | import type { ConfigDefaults } from "../types"; 4 | 5 | let echoInstance: Echo | null = null; 6 | let echoConfig: EchoOptions | null = null; 7 | 8 | const getEchoInstance = (): Echo => { 9 | if (echoInstance) { 10 | return echoInstance as Echo; 11 | } 12 | 13 | if (!echoConfig) { 14 | throw new Error( 15 | "Echo has not been configured. Please call `configureEcho()`.", 16 | ); 17 | } 18 | 19 | echoConfig.Pusher ??= Pusher; 20 | 21 | echoInstance = new Echo(echoConfig); 22 | 23 | return echoInstance as Echo; 24 | }; 25 | 26 | /** 27 | * Configure the Echo instance with sensible defaults. 28 | * 29 | * @link https://laravel.com/docs/broadcasting#client-side-installation 30 | */ 31 | export const configureEcho = ( 32 | config: EchoOptions, 33 | ): void => { 34 | const defaults: ConfigDefaults = { 35 | reverb: { 36 | broadcaster: "reverb", 37 | key: import.meta.env.VITE_REVERB_APP_KEY, 38 | wsHost: import.meta.env.VITE_REVERB_HOST, 39 | wsPort: import.meta.env.VITE_REVERB_PORT, 40 | wssPort: import.meta.env.VITE_REVERB_PORT, 41 | forceTLS: 42 | (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https", 43 | enabledTransports: ["ws", "wss"], 44 | }, 45 | pusher: { 46 | broadcaster: "pusher", 47 | key: import.meta.env.VITE_PUSHER_APP_KEY, 48 | cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, 49 | forceTLS: true, 50 | wsHost: import.meta.env.VITE_PUSHER_HOST, 51 | wsPort: import.meta.env.VITE_PUSHER_PORT, 52 | wssPort: import.meta.env.VITE_PUSHER_PORT, 53 | enabledTransports: ["ws", "wss"], 54 | }, 55 | "socket.io": { 56 | broadcaster: "socket.io", 57 | host: import.meta.env.VITE_SOCKET_IO_HOST, 58 | }, 59 | null: { 60 | broadcaster: "null", 61 | }, 62 | ably: { 63 | broadcaster: "pusher", 64 | key: import.meta.env.VITE_ABLY_PUBLIC_KEY, 65 | wsHost: "realtime-pusher.ably.io", 66 | wsPort: 443, 67 | disableStats: true, 68 | encrypted: true, 69 | }, 70 | }; 71 | 72 | echoConfig = { 73 | ...defaults[config.broadcaster], 74 | ...config, 75 | } as EchoOptions; 76 | 77 | // Reset the instance if it was already created 78 | if (echoInstance) { 79 | echoInstance = null; 80 | } 81 | }; 82 | 83 | export const echo = (): Echo => 84 | getEchoInstance(); 85 | -------------------------------------------------------------------------------- /packages/react/src/hooks/use-echo.ts: -------------------------------------------------------------------------------- 1 | import { type BroadcastDriver } from "laravel-echo"; 2 | import { useCallback, useEffect, useRef } from "react"; 3 | import { echo } from "../config"; 4 | import type { 5 | BroadcastNotification, 6 | Channel, 7 | ChannelData, 8 | ChannelReturnType, 9 | Connection, 10 | ModelEvents, 11 | ModelPayload, 12 | } from "../types"; 13 | import { toArray } from "../util"; 14 | 15 | const channels: Record> = {}; 16 | 17 | const subscribeToChannel = ( 18 | channel: Channel, 19 | ): Connection => { 20 | const instance = echo(); 21 | 22 | if (channel.visibility === "presence") { 23 | return instance.join(channel.name); 24 | } 25 | 26 | if (channel.visibility === "private") { 27 | return instance.private(channel.name); 28 | } 29 | 30 | return instance.channel(channel.name); 31 | }; 32 | 33 | const leaveChannel = (channel: Channel, leaveAll: boolean): void => { 34 | if (!channels[channel.id]) { 35 | return; 36 | } 37 | 38 | channels[channel.id].count -= 1; 39 | 40 | if (channels[channel.id].count > 0) { 41 | return; 42 | } 43 | 44 | if (leaveAll) { 45 | echo().leave(channel.name); 46 | } else { 47 | echo().leaveChannel(channel.id); 48 | } 49 | 50 | delete channels[channel.id]; 51 | }; 52 | 53 | const resolveChannelSubscription = ( 54 | channel: Channel, 55 | ): Connection => { 56 | if (channels[channel.id]) { 57 | channels[channel.id].count += 1; 58 | 59 | return channels[channel.id].connection; 60 | } 61 | 62 | const channelSubscription = subscribeToChannel(channel); 63 | 64 | channels[channel.id] = { 65 | count: 1, 66 | connection: channelSubscription, 67 | }; 68 | 69 | return channelSubscription; 70 | }; 71 | 72 | export const useEcho = < 73 | TPayload, 74 | TDriver extends BroadcastDriver = BroadcastDriver, 75 | TVisibility extends Channel["visibility"] = "private", 76 | >( 77 | channelName: string, 78 | event: string | string[] = [], 79 | callback: (payload: TPayload) => void = () => {}, 80 | dependencies: any[] = [], 81 | visibility: TVisibility = "private" as TVisibility, 82 | ) => { 83 | const channel: Channel = { 84 | name: channelName, 85 | id: ["private", "presence"].includes(visibility) 86 | ? `${visibility}-${channelName}` 87 | : channelName, 88 | visibility, 89 | }; 90 | 91 | const callbackFunc = useCallback(callback, dependencies); 92 | const listening = useRef(false); 93 | const initialized = useRef(false); 94 | const subscription = useRef>( 95 | resolveChannelSubscription(channel), 96 | ); 97 | 98 | const events = toArray(event); 99 | 100 | const stopListening = useCallback(() => { 101 | if (!listening.current) { 102 | return; 103 | } 104 | 105 | events.forEach((e) => { 106 | subscription.current.stopListening(e, callbackFunc); 107 | }); 108 | 109 | listening.current = false; 110 | }, dependencies); 111 | 112 | const listen = useCallback(() => { 113 | if (listening.current) { 114 | return; 115 | } 116 | 117 | events.forEach((e) => { 118 | subscription.current.listen(e, callbackFunc); 119 | }); 120 | 121 | listening.current = true; 122 | }, dependencies); 123 | 124 | const tearDown = useCallback((leaveAll: boolean = false) => { 125 | stopListening(); 126 | 127 | leaveChannel(channel, leaveAll); 128 | }, dependencies); 129 | 130 | useEffect(() => { 131 | if (initialized.current) { 132 | subscription.current = resolveChannelSubscription(channel); 133 | } 134 | 135 | initialized.current = true; 136 | 137 | listen(); 138 | 139 | return tearDown; 140 | }, dependencies); 141 | 142 | return { 143 | /** 144 | * Leave the channel 145 | */ 146 | leaveChannel: tearDown, 147 | /** 148 | * Leave the channel and also its associated private and presence channels 149 | */ 150 | leave: () => tearDown(true), 151 | /** 152 | * Stop listening for event(s) without leaving the channel 153 | */ 154 | stopListening, 155 | /** 156 | * Listen for event(s) 157 | */ 158 | listen, 159 | /** 160 | * Channel instance 161 | */ 162 | channel: () => 163 | subscription.current as ChannelReturnType, 164 | }; 165 | }; 166 | 167 | export const useEchoNotification = < 168 | TPayload, 169 | TDriver extends BroadcastDriver = BroadcastDriver, 170 | >( 171 | channelName: string, 172 | callback: (payload: BroadcastNotification) => void = () => {}, 173 | event: string | string[] = [], 174 | dependencies: any[] = [], 175 | ) => { 176 | const result = useEcho, TDriver, "private">( 177 | channelName, 178 | [], 179 | callback, 180 | dependencies, 181 | "private", 182 | ); 183 | 184 | const events = useRef( 185 | toArray(event) 186 | .map((e) => { 187 | if (e.includes(".")) { 188 | return [e, e.replace(/\./g, "\\")]; 189 | } 190 | 191 | return [e, e.replace(/\\/g, ".")]; 192 | }) 193 | .flat(), 194 | ); 195 | const listening = useRef(false); 196 | const initialized = useRef(false); 197 | 198 | const cb = useCallback( 199 | (notification: BroadcastNotification) => { 200 | if (!listening.current) { 201 | return; 202 | } 203 | 204 | if ( 205 | events.current.length === 0 || 206 | events.current.includes(notification.type) 207 | ) { 208 | callback(notification); 209 | } 210 | }, 211 | dependencies.concat(events.current).concat([callback]), 212 | ); 213 | 214 | const listen = useCallback(() => { 215 | if (listening.current) { 216 | return; 217 | } 218 | 219 | if (!initialized.current) { 220 | result.channel().notification(cb); 221 | } 222 | 223 | listening.current = true; 224 | initialized.current = true; 225 | }, [cb]); 226 | 227 | const stopListening = useCallback(() => { 228 | if (!listening.current) { 229 | return; 230 | } 231 | 232 | listening.current = false; 233 | }, [cb]); 234 | 235 | useEffect(() => { 236 | listen(); 237 | }, dependencies.concat(events.current)); 238 | 239 | return { 240 | ...result, 241 | /** 242 | * Stop listening for notification events 243 | */ 244 | stopListening, 245 | /** 246 | * Listen for notification events 247 | */ 248 | listen, 249 | }; 250 | }; 251 | 252 | export const useEchoPresence = < 253 | TPayload, 254 | TDriver extends BroadcastDriver = BroadcastDriver, 255 | >( 256 | channelName: string, 257 | event: string | string[] = [], 258 | callback: (payload: TPayload) => void = () => {}, 259 | dependencies: any[] = [], 260 | ) => { 261 | return useEcho( 262 | channelName, 263 | event, 264 | callback, 265 | dependencies, 266 | "presence", 267 | ); 268 | }; 269 | 270 | export const useEchoPublic = < 271 | TPayload, 272 | TDriver extends BroadcastDriver = BroadcastDriver, 273 | >( 274 | channelName: string, 275 | event: string | string[] = [], 276 | callback: (payload: TPayload) => void = () => {}, 277 | dependencies: any[] = [], 278 | ) => { 279 | return useEcho( 280 | channelName, 281 | event, 282 | callback, 283 | dependencies, 284 | "public", 285 | ); 286 | }; 287 | 288 | export const useEchoModel = < 289 | TPayload, 290 | TModel extends string, 291 | TDriver extends BroadcastDriver = BroadcastDriver, 292 | >( 293 | model: TModel, 294 | identifier: string | number, 295 | event: ModelEvents | ModelEvents[] = [], 296 | callback: (payload: ModelPayload) => void = () => {}, 297 | dependencies: any[] = [], 298 | ) => { 299 | return useEcho, TDriver, "private">( 300 | `${model}.${identifier}`, 301 | toArray(event).map((e) => (e.startsWith(".") ? e : `.${e}`)), 302 | callback, 303 | dependencies, 304 | "private", 305 | ); 306 | }; 307 | -------------------------------------------------------------------------------- /packages/react/src/index.iife.ts: -------------------------------------------------------------------------------- 1 | export { configureEcho, echo } from "./config/index"; 2 | export { 3 | useEcho, 4 | useEchoModel, 5 | useEchoPresence, 6 | useEchoPublic, 7 | } from "./hooks/use-echo"; 8 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export { configureEcho, echo } from "./config/index"; 2 | export { 3 | useEcho, 4 | useEchoModel, 5 | useEchoNotification, 6 | useEchoPresence, 7 | useEchoPublic, 8 | } from "./hooks/use-echo"; 9 | -------------------------------------------------------------------------------- /packages/react/src/types.ts: -------------------------------------------------------------------------------- 1 | import { type BroadcastDriver, type Broadcaster } from "laravel-echo"; 2 | 3 | export type Connection = 4 | | Broadcaster[T]["public"] 5 | | Broadcaster[T]["private"] 6 | | Broadcaster[T]["presence"]; 7 | 8 | export type ChannelData = { 9 | count: number; 10 | connection: Connection; 11 | }; 12 | 13 | export type Channel = { 14 | name: string; 15 | id: string; 16 | visibility: "private" | "public" | "presence"; 17 | }; 18 | 19 | export type BroadcastNotification = TPayload & { 20 | id: string; 21 | type: string; 22 | }; 23 | 24 | export type ChannelReturnType< 25 | T extends BroadcastDriver, 26 | V extends Channel["visibility"], 27 | > = V extends "presence" 28 | ? Broadcaster[T]["presence"] 29 | : V extends "private" 30 | ? Broadcaster[T]["private"] 31 | : Broadcaster[T]["public"]; 32 | 33 | export type ConfigDefaults = Record< 34 | O, 35 | Broadcaster[O]["options"] 36 | >; 37 | 38 | export type ModelPayload = { 39 | model: T; 40 | connection: string | null; 41 | queue: string | null; 42 | afterCommit: boolean; 43 | }; 44 | 45 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 46 | export type ModelName = T extends `${infer _}.${infer U}` 47 | ? ModelName 48 | : T; 49 | 50 | type ModelEvent = 51 | | "Retrieved" 52 | | "Creating" 53 | | "Created" 54 | | "Updating" 55 | | "Updated" 56 | | "Saving" 57 | | "Saved" 58 | | "Deleting" 59 | | "Deleted" 60 | | "Trashed" 61 | | "ForceDeleting" 62 | | "ForceDeleted" 63 | | "Restoring" 64 | | "Restored" 65 | | "Replicating"; 66 | 67 | export type ModelEvents = 68 | | `.${ModelName}${ModelEvent}` 69 | | `${ModelName}${ModelEvent}`; 70 | -------------------------------------------------------------------------------- /packages/react/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export const toArray = (item: T | T[]): T[] => 2 | Array.isArray(item) ? item : [item]; 3 | -------------------------------------------------------------------------------- /packages/react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_PUSHER_APP_CLUSTER: string; 5 | readonly VITE_PUSHER_APP_KEY: string; 6 | readonly VITE_PUSHER_HOST: string; 7 | readonly VITE_PUSHER_PORT: number; 8 | 9 | readonly VITE_REVERB_HOST: string; 10 | readonly VITE_REVERB_APP_KEY: string; 11 | readonly VITE_REVERB_PORT: number; 12 | readonly VITE_REVERB_SCHEME: string; 13 | 14 | readonly VITE_SOCKET_IO_HOST: string; 15 | 16 | readonly VITE_ABLY_PUBLIC_KEY: string; 17 | } 18 | 19 | interface ImportMeta { 20 | readonly env: ImportMetaEnv; 21 | } 22 | -------------------------------------------------------------------------------- /packages/react/tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | import { configureEcho, echo } from "../src"; 3 | 4 | describe("echo helper", async () => { 5 | beforeEach(() => { 6 | vi.resetModules(); 7 | }); 8 | 9 | it("throws error when Echo is not configured", async () => { 10 | expect(() => echo()).toThrow("Echo has not been configured"); 11 | }); 12 | 13 | it("creates Echo instance with proper configuration", async () => { 14 | configureEcho({ 15 | broadcaster: "null", 16 | }); 17 | 18 | expect(echo()).toBeDefined(); 19 | expect(echo().options.broadcaster).toBe("null"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/react/tests/use-echo.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import Echo from "laravel-echo"; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 | 5 | const getEchoModule = async () => import("../src/hooks/use-echo"); 6 | const getConfigModule = async () => import("../src/config/index"); 7 | 8 | vi.mock("laravel-echo", () => { 9 | const mockPrivateChannel = { 10 | leaveChannel: vi.fn(), 11 | listen: vi.fn(), 12 | stopListening: vi.fn(), 13 | notification: vi.fn(), 14 | }; 15 | 16 | const mockPublicChannel = { 17 | leaveChannel: vi.fn(), 18 | listen: vi.fn(), 19 | stopListening: vi.fn(), 20 | }; 21 | 22 | const mockPresenceChannel = { 23 | leaveChannel: vi.fn(), 24 | listen: vi.fn(), 25 | stopListening: vi.fn(), 26 | here: vi.fn(), 27 | joining: vi.fn(), 28 | leaving: vi.fn(), 29 | whisper: vi.fn(), 30 | }; 31 | 32 | const Echo = vi.fn(); 33 | 34 | Echo.prototype.private = vi.fn(() => mockPrivateChannel); 35 | Echo.prototype.channel = vi.fn(() => mockPublicChannel); 36 | Echo.prototype.encryptedPrivate = vi.fn(); 37 | Echo.prototype.listen = vi.fn(); 38 | Echo.prototype.leave = vi.fn(); 39 | Echo.prototype.leaveChannel = vi.fn(); 40 | Echo.prototype.leaveAllChannels = vi.fn(); 41 | Echo.prototype.join = vi.fn(() => mockPresenceChannel); 42 | 43 | return { default: Echo }; 44 | }); 45 | 46 | describe("without echo configured", async () => { 47 | beforeEach(() => { 48 | vi.resetModules(); 49 | }); 50 | 51 | it("throws error when Echo is not configured", async () => { 52 | const echoModule = await getEchoModule(); 53 | const mockCallback = vi.fn(); 54 | const channelName = "test-channel"; 55 | const event = "test-event"; 56 | 57 | expect(() => 58 | renderHook(() => 59 | echoModule.useEcho( 60 | channelName, 61 | event, 62 | mockCallback, 63 | [], 64 | "private", 65 | ), 66 | ), 67 | ).toThrow("Echo has not been configured"); 68 | }); 69 | }); 70 | 71 | describe("useEcho hook", async () => { 72 | let echoModule: typeof import("../src/hooks/use-echo"); 73 | let configModule: typeof import("../src/config/index"); 74 | let echoInstance: Echo<"null">; 75 | 76 | beforeEach(async () => { 77 | vi.resetModules(); 78 | 79 | echoInstance = new Echo({ 80 | broadcaster: "null", 81 | }); 82 | 83 | echoModule = await getEchoModule(); 84 | configModule = await getConfigModule(); 85 | 86 | configModule.configureEcho({ 87 | broadcaster: "null", 88 | }); 89 | }); 90 | 91 | afterEach(() => { 92 | vi.clearAllMocks(); 93 | }); 94 | 95 | it("subscribes to a channel and listens for events", async () => { 96 | const mockCallback = vi.fn(); 97 | const channelName = "test-channel"; 98 | const event = "test-event"; 99 | 100 | const { result } = renderHook(() => 101 | echoModule.useEcho(channelName, event, mockCallback), 102 | ); 103 | 104 | expect(result.current).toHaveProperty("leaveChannel"); 105 | expect(typeof result.current.leave).toBe("function"); 106 | 107 | expect(result.current).toHaveProperty("leave"); 108 | expect(typeof result.current.leaveChannel).toBe("function"); 109 | }); 110 | 111 | it("handles multiple events", async () => { 112 | const mockCallback = vi.fn(); 113 | const channelName = "test-channel"; 114 | const events = ["event1", "event2"]; 115 | 116 | const { result, unmount } = renderHook(() => 117 | echoModule.useEcho(channelName, events, mockCallback), 118 | ); 119 | 120 | expect(result.current).toHaveProperty("leaveChannel"); 121 | 122 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 123 | 124 | const channel = echoInstance.private(channelName); 125 | 126 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 127 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 128 | 129 | expect(() => unmount()).not.toThrow(); 130 | 131 | expect(channel.stopListening).toHaveBeenCalledWith( 132 | events[0], 133 | mockCallback, 134 | ); 135 | expect(channel.stopListening).toHaveBeenCalledWith( 136 | events[1], 137 | mockCallback, 138 | ); 139 | }); 140 | 141 | it("cleans up subscriptions on unmount", async () => { 142 | const mockCallback = vi.fn(); 143 | const channelName = "test-channel"; 144 | const event = "test-event"; 145 | 146 | const { unmount } = renderHook(() => 147 | echoModule.useEcho(channelName, event, mockCallback), 148 | ); 149 | 150 | expect(echoInstance.private).toHaveBeenCalled(); 151 | 152 | expect(() => unmount()).not.toThrow(); 153 | 154 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 155 | }); 156 | 157 | it("won't subscribe multiple times to the same channel", async () => { 158 | const mockCallback = vi.fn(); 159 | const channelName = "test-channel"; 160 | const event = "test-event"; 161 | 162 | const { unmount: unmount1 } = renderHook(() => 163 | echoModule.useEcho(channelName, event, mockCallback), 164 | ); 165 | 166 | const { unmount: unmount2 } = renderHook(() => 167 | echoModule.useEcho(channelName, event, mockCallback), 168 | ); 169 | 170 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 171 | 172 | expect(() => unmount1()).not.toThrow(); 173 | 174 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 175 | 176 | expect(() => unmount2()).not.toThrow(); 177 | 178 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 179 | }); 180 | 181 | it("will register callbacks for events", async () => { 182 | const mockCallback = vi.fn(); 183 | const channelName = "test-channel"; 184 | const event = "test-event"; 185 | 186 | const { unmount } = renderHook(() => 187 | echoModule.useEcho(channelName, event, mockCallback), 188 | ); 189 | 190 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 191 | 192 | expect(echoInstance.private(channelName).listen).toHaveBeenCalledWith( 193 | event, 194 | mockCallback, 195 | ); 196 | }); 197 | 198 | it("can leave a channel", async () => { 199 | const mockCallback = vi.fn(); 200 | const channelName = "test-channel"; 201 | const event = "test-event"; 202 | 203 | const { result } = renderHook(() => 204 | echoModule.useEcho(channelName, event, mockCallback), 205 | ); 206 | 207 | result.current.leaveChannel(); 208 | 209 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 210 | "private-" + channelName, 211 | ); 212 | }); 213 | 214 | it("can leave all channel variations", async () => { 215 | const mockCallback = vi.fn(); 216 | const channelName = "test-channel"; 217 | const event = "test-event"; 218 | 219 | const { result } = renderHook(() => 220 | echoModule.useEcho(channelName, event, mockCallback), 221 | ); 222 | 223 | result.current.leave(); 224 | 225 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 226 | }); 227 | 228 | it("can connect to a public channel", async () => { 229 | const mockCallback = vi.fn(); 230 | const channelName = "test-channel"; 231 | const event = "test-event"; 232 | 233 | const { result } = renderHook(() => 234 | echoModule.useEcho(channelName, event, mockCallback, [], "public"), 235 | ); 236 | 237 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 238 | 239 | result.current.leaveChannel(); 240 | 241 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 242 | }); 243 | 244 | it("can manually start listening to events", async () => { 245 | const mockCallback = vi.fn(); 246 | const channelName = "test-channel"; 247 | const event = "test-event"; 248 | 249 | const { result } = renderHook(() => 250 | echoModule.useEcho(channelName, event, mockCallback), 251 | ); 252 | 253 | const channel = echoInstance.private(channelName); 254 | 255 | expect(channel.listen).toHaveBeenCalledWith(event, mockCallback); 256 | 257 | result.current.stopListening(); 258 | 259 | expect(channel.stopListening).toHaveBeenCalledWith(event, mockCallback); 260 | 261 | result.current.listen(); 262 | 263 | expect(channel.listen).toHaveBeenCalledWith(event, mockCallback); 264 | }); 265 | 266 | it("can manually stop listening to events", async () => { 267 | const mockCallback = vi.fn(); 268 | const channelName = "test-channel"; 269 | const event = "test-event"; 270 | 271 | const { result } = renderHook(() => 272 | echoModule.useEcho(channelName, event, mockCallback), 273 | ); 274 | 275 | result.current.stopListening(); 276 | 277 | const channel = echoInstance.private(channelName); 278 | expect(channel.stopListening).toHaveBeenCalledWith(event, mockCallback); 279 | }); 280 | 281 | it("stopListening is a no-op when not listening", async () => { 282 | const mockCallback = vi.fn(); 283 | const channelName = "test-channel"; 284 | const event = "test-event"; 285 | 286 | const { result } = renderHook(() => 287 | echoModule.useEcho(channelName, event, mockCallback), 288 | ); 289 | 290 | result.current.stopListening(); 291 | result.current.stopListening(); 292 | 293 | const channel = echoInstance.private(channelName); 294 | expect(channel.stopListening).toHaveBeenCalledTimes(1); 295 | }); 296 | 297 | it("listen is a no-op when already listening", async () => { 298 | const mockCallback = vi.fn(); 299 | const channelName = "test-channel"; 300 | const event = "test-event"; 301 | 302 | const { result } = renderHook(() => 303 | echoModule.useEcho(channelName, event, mockCallback), 304 | ); 305 | 306 | result.current.listen(); 307 | 308 | const channel = echoInstance.private(channelName); 309 | expect(channel.listen).toHaveBeenCalledTimes(1); 310 | }); 311 | 312 | it("events and listeners are optional", async () => { 313 | const channelName = "test-channel"; 314 | 315 | const { result } = renderHook(() => echoModule.useEcho(channelName)); 316 | 317 | expect(result.current).toHaveProperty("channel"); 318 | expect(result.current.channel).not.toBeNull(); 319 | }); 320 | }); 321 | 322 | describe("useEchoModel hook", async () => { 323 | let echoModule: typeof import("../src/hooks/use-echo"); 324 | let configModule: typeof import("../src/config/index"); 325 | let echoInstance: Echo<"null">; 326 | 327 | beforeEach(async () => { 328 | vi.resetModules(); 329 | 330 | echoInstance = new Echo({ 331 | broadcaster: "null", 332 | }); 333 | 334 | echoModule = await getEchoModule(); 335 | configModule = await getConfigModule(); 336 | 337 | configModule.configureEcho({ 338 | broadcaster: "null", 339 | }); 340 | }); 341 | 342 | afterEach(() => { 343 | vi.clearAllMocks(); 344 | }); 345 | 346 | it("subscribes to model channel and listens for model events", async () => { 347 | const mockCallback = vi.fn(); 348 | const model = "App.Models.User"; 349 | const identifier = "123"; 350 | const event = "UserCreated"; 351 | 352 | const { result } = renderHook(() => 353 | echoModule.useEchoModel( 354 | model, 355 | identifier, 356 | event, 357 | mockCallback, 358 | ), 359 | ); 360 | 361 | expect(result.current).toHaveProperty("leaveChannel"); 362 | expect(typeof result.current.leave).toBe("function"); 363 | expect(result.current).toHaveProperty("leave"); 364 | expect(typeof result.current.leaveChannel).toBe("function"); 365 | }); 366 | 367 | it("handles multiple model events", async () => { 368 | const mockCallback = vi.fn(); 369 | const model = "App.Models.User"; 370 | const identifier = "123"; 371 | const events = ["UserCreated", "UserUpdated"]; 372 | 373 | const { result, unmount } = renderHook(() => 374 | echoModule.useEchoModel( 375 | model, 376 | identifier, 377 | ["UserCreated", "UserUpdated"], 378 | mockCallback, 379 | ), 380 | ); 381 | 382 | expect(result.current).toHaveProperty("leaveChannel"); 383 | 384 | const expectedChannelName = `${model}.${identifier}`; 385 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 386 | 387 | const channel = echoInstance.private(expectedChannelName); 388 | 389 | expect(channel.listen).toHaveBeenCalledWith( 390 | `.${events[0]}`, 391 | mockCallback, 392 | ); 393 | expect(channel.listen).toHaveBeenCalledWith( 394 | `.${events[1]}`, 395 | mockCallback, 396 | ); 397 | 398 | expect(() => unmount()).not.toThrow(); 399 | 400 | expect(channel.stopListening).toHaveBeenCalledWith( 401 | `.${events[0]}`, 402 | mockCallback, 403 | ); 404 | expect(channel.stopListening).toHaveBeenCalledWith( 405 | `.${events[1]}`, 406 | mockCallback, 407 | ); 408 | }); 409 | 410 | it("cleans up subscriptions on unmount", async () => { 411 | const mockCallback = vi.fn(); 412 | const model = "App.Models.User"; 413 | const identifier = "123"; 414 | const event = "UserCreated"; 415 | 416 | const { unmount } = renderHook(() => 417 | echoModule.useEchoModel( 418 | model, 419 | identifier, 420 | event, 421 | mockCallback, 422 | ), 423 | ); 424 | 425 | const expectedChannelName = `${model}.${identifier}`; 426 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 427 | 428 | expect(() => unmount()).not.toThrow(); 429 | 430 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 431 | `private-${expectedChannelName}`, 432 | ); 433 | }); 434 | 435 | it("won't subscribe multiple times to the same model channel", async () => { 436 | const mockCallback = vi.fn(); 437 | const model = "App.Models.User"; 438 | const identifier = "123"; 439 | const event = "UserCreated"; 440 | 441 | const { unmount: unmount1 } = renderHook(() => 442 | echoModule.useEchoModel( 443 | model, 444 | identifier, 445 | event, 446 | mockCallback, 447 | ), 448 | ); 449 | 450 | const { unmount: unmount2 } = renderHook(() => 451 | echoModule.useEchoModel( 452 | model, 453 | identifier, 454 | event, 455 | mockCallback, 456 | ), 457 | ); 458 | 459 | const expectedChannelName = `${model}.${identifier}`; 460 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 461 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 462 | 463 | expect(() => unmount1()).not.toThrow(); 464 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 465 | 466 | expect(() => unmount2()).not.toThrow(); 467 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 468 | `private-${expectedChannelName}`, 469 | ); 470 | }); 471 | 472 | it("can leave a model channel", async () => { 473 | const mockCallback = vi.fn(); 474 | const model = "App.Models.User"; 475 | const identifier = "123"; 476 | const event = "UserCreated"; 477 | 478 | const { result } = renderHook(() => 479 | echoModule.useEchoModel( 480 | model, 481 | identifier, 482 | event, 483 | mockCallback, 484 | ), 485 | ); 486 | 487 | result.current.leaveChannel(); 488 | 489 | const expectedChannelName = `${model}.${identifier}`; 490 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 491 | `private-${expectedChannelName}`, 492 | ); 493 | }); 494 | 495 | it("can leave all model channel variations", async () => { 496 | const mockCallback = vi.fn(); 497 | const model = "App.Models.User"; 498 | const identifier = "123"; 499 | const event = "UserCreated"; 500 | 501 | const { result } = renderHook(() => 502 | echoModule.useEchoModel( 503 | model, 504 | identifier, 505 | event, 506 | mockCallback, 507 | ), 508 | ); 509 | 510 | result.current.leave(); 511 | 512 | const expectedChannelName = `${model}.${identifier}`; 513 | expect(echoInstance.leave).toHaveBeenCalledWith(expectedChannelName); 514 | }); 515 | 516 | it("handles model events with dots in the name", async () => { 517 | const mockCallback = vi.fn(); 518 | const model = "App.Models.User.Profile"; 519 | const identifier = "123"; 520 | const event = "ProfileCreated"; 521 | 522 | const { result } = renderHook(() => 523 | echoModule.useEchoModel( 524 | model, 525 | identifier, 526 | event, 527 | mockCallback, 528 | ), 529 | ); 530 | 531 | const expectedChannelName = `${model}.${identifier}`; 532 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 533 | 534 | const channel = echoInstance.private(expectedChannelName); 535 | expect(channel.listen).toHaveBeenCalledWith(`.${event}`, mockCallback); 536 | }); 537 | 538 | it("events and listeners are optional", async () => { 539 | const model = "App.Models.User.Profile"; 540 | const identifier = "123"; 541 | 542 | const { result } = renderHook(() => 543 | echoModule.useEchoModel(model, identifier), 544 | ); 545 | 546 | expect(result.current).toHaveProperty("channel"); 547 | expect(result.current.channel).not.toBeNull(); 548 | }); 549 | }); 550 | 551 | describe("useEchoPublic hook", async () => { 552 | let echoModule: typeof import("../src/hooks/use-echo"); 553 | let configModule: typeof import("../src/config/index"); 554 | let echoInstance: Echo<"null">; 555 | 556 | beforeEach(async () => { 557 | vi.resetModules(); 558 | 559 | echoInstance = new Echo({ 560 | broadcaster: "null", 561 | }); 562 | 563 | echoModule = await getEchoModule(); 564 | configModule = await getConfigModule(); 565 | 566 | configModule.configureEcho({ 567 | broadcaster: "null", 568 | }); 569 | }); 570 | 571 | afterEach(() => { 572 | vi.clearAllMocks(); 573 | }); 574 | 575 | it("subscribes to a public channel and listens for events", async () => { 576 | const mockCallback = vi.fn(); 577 | const channelName = "test-channel"; 578 | const event = "test-event"; 579 | 580 | const { result } = renderHook(() => 581 | echoModule.useEchoPublic(channelName, event, mockCallback), 582 | ); 583 | 584 | expect(result.current).toHaveProperty("leaveChannel"); 585 | expect(typeof result.current.leave).toBe("function"); 586 | expect(result.current).toHaveProperty("leave"); 587 | expect(typeof result.current.leaveChannel).toBe("function"); 588 | }); 589 | 590 | it("handles multiple events", async () => { 591 | const mockCallback = vi.fn(); 592 | const channelName = "test-channel"; 593 | const events = ["event1", "event2"]; 594 | 595 | const { result, unmount } = renderHook(() => 596 | echoModule.useEchoPublic(channelName, events, mockCallback), 597 | ); 598 | 599 | expect(result.current).toHaveProperty("leaveChannel"); 600 | 601 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 602 | 603 | const channel = echoInstance.channel(channelName); 604 | 605 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 606 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 607 | 608 | expect(() => unmount()).not.toThrow(); 609 | 610 | expect(channel.stopListening).toHaveBeenCalledWith( 611 | events[0], 612 | mockCallback, 613 | ); 614 | expect(channel.stopListening).toHaveBeenCalledWith( 615 | events[1], 616 | mockCallback, 617 | ); 618 | }); 619 | 620 | it("cleans up subscriptions on unmount", async () => { 621 | const mockCallback = vi.fn(); 622 | const channelName = "test-channel"; 623 | const event = "test-event"; 624 | 625 | const { unmount } = renderHook(() => 626 | echoModule.useEchoPublic(channelName, event, mockCallback), 627 | ); 628 | 629 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 630 | 631 | expect(() => unmount()).not.toThrow(); 632 | 633 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 634 | }); 635 | 636 | it("won't subscribe multiple times to the same channel", async () => { 637 | const mockCallback = vi.fn(); 638 | const channelName = "test-channel"; 639 | const event = "test-event"; 640 | 641 | const { unmount: unmount1 } = renderHook(() => 642 | echoModule.useEchoPublic(channelName, event, mockCallback), 643 | ); 644 | 645 | const { unmount: unmount2 } = renderHook(() => 646 | echoModule.useEchoPublic(channelName, event, mockCallback), 647 | ); 648 | 649 | expect(echoInstance.channel).toHaveBeenCalledTimes(1); 650 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 651 | 652 | expect(() => unmount1()).not.toThrow(); 653 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 654 | 655 | expect(() => unmount2()).not.toThrow(); 656 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 657 | }); 658 | 659 | it("can leave a channel", async () => { 660 | const mockCallback = vi.fn(); 661 | const channelName = "test-channel"; 662 | const event = "test-event"; 663 | 664 | const { result } = renderHook(() => 665 | echoModule.useEchoPublic(channelName, event, mockCallback), 666 | ); 667 | 668 | result.current.leaveChannel(); 669 | 670 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 671 | }); 672 | 673 | it("can leave all channel variations", async () => { 674 | const mockCallback = vi.fn(); 675 | const channelName = "test-channel"; 676 | const event = "test-event"; 677 | 678 | const { result } = renderHook(() => 679 | echoModule.useEchoPublic(channelName, event, mockCallback), 680 | ); 681 | 682 | result.current.leave(); 683 | 684 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 685 | }); 686 | 687 | it("events and listeners are optional", async () => { 688 | const channelName = "test-channel"; 689 | 690 | const { result } = renderHook(() => 691 | echoModule.useEchoPublic(channelName), 692 | ); 693 | 694 | expect(result.current).toHaveProperty("channel"); 695 | expect(result.current.channel).not.toBeNull(); 696 | }); 697 | }); 698 | 699 | describe("useEchoPresence hook", async () => { 700 | let echoModule: typeof import("../src/hooks/use-echo"); 701 | let configModule: typeof import("../src/config/index"); 702 | let echoInstance: Echo<"null">; 703 | 704 | beforeEach(async () => { 705 | vi.resetModules(); 706 | 707 | echoInstance = new Echo({ 708 | broadcaster: "null", 709 | }); 710 | 711 | echoModule = await getEchoModule(); 712 | configModule = await getConfigModule(); 713 | 714 | configModule.configureEcho({ 715 | broadcaster: "null", 716 | }); 717 | }); 718 | 719 | afterEach(() => { 720 | vi.clearAllMocks(); 721 | }); 722 | 723 | it("subscribes to a presence channel and listens for events", async () => { 724 | const mockCallback = vi.fn(); 725 | const channelName = "test-channel"; 726 | const event = "test-event"; 727 | 728 | const { result } = renderHook(() => 729 | echoModule.useEchoPresence(channelName, event, mockCallback), 730 | ); 731 | 732 | expect(result.current).toHaveProperty("leaveChannel"); 733 | expect(typeof result.current.leave).toBe("function"); 734 | expect(result.current).toHaveProperty("leave"); 735 | expect(typeof result.current.leaveChannel).toBe("function"); 736 | expect(result.current).toHaveProperty("channel"); 737 | expect(result.current.channel).not.toBeNull(); 738 | expect(typeof result.current.channel().here).toBe("function"); 739 | expect(typeof result.current.channel().joining).toBe("function"); 740 | expect(typeof result.current.channel().leaving).toBe("function"); 741 | expect(typeof result.current.channel().whisper).toBe("function"); 742 | }); 743 | 744 | it("handles multiple events", async () => { 745 | const mockCallback = vi.fn(); 746 | const channelName = "test-channel"; 747 | const events = ["event1", "event2"]; 748 | 749 | const { result, unmount } = renderHook(() => 750 | echoModule.useEchoPresence(channelName, events, mockCallback), 751 | ); 752 | 753 | expect(result.current).toHaveProperty("leaveChannel"); 754 | 755 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 756 | 757 | const channel = echoInstance.join(channelName); 758 | 759 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 760 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 761 | 762 | expect(() => unmount()).not.toThrow(); 763 | 764 | expect(channel.stopListening).toHaveBeenCalledWith( 765 | events[0], 766 | mockCallback, 767 | ); 768 | expect(channel.stopListening).toHaveBeenCalledWith( 769 | events[1], 770 | mockCallback, 771 | ); 772 | }); 773 | 774 | it("cleans up subscriptions on unmount", async () => { 775 | const mockCallback = vi.fn(); 776 | const channelName = "test-channel"; 777 | const event = "test-event"; 778 | 779 | const { unmount } = renderHook(() => 780 | echoModule.useEchoPresence(channelName, event, mockCallback), 781 | ); 782 | 783 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 784 | 785 | expect(() => unmount()).not.toThrow(); 786 | 787 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 788 | `presence-${channelName}`, 789 | ); 790 | }); 791 | 792 | it("won't subscribe multiple times to the same channel", async () => { 793 | const mockCallback = vi.fn(); 794 | const channelName = "test-channel"; 795 | const event = "test-event"; 796 | 797 | const { unmount: unmount1 } = renderHook(() => 798 | echoModule.useEchoPresence(channelName, event, mockCallback), 799 | ); 800 | 801 | const { unmount: unmount2 } = renderHook(() => 802 | echoModule.useEchoPresence(channelName, event, mockCallback), 803 | ); 804 | 805 | expect(echoInstance.join).toHaveBeenCalledTimes(1); 806 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 807 | 808 | expect(() => unmount1()).not.toThrow(); 809 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 810 | 811 | expect(() => unmount2()).not.toThrow(); 812 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 813 | `presence-${channelName}`, 814 | ); 815 | }); 816 | 817 | it("can leave a channel", async () => { 818 | const mockCallback = vi.fn(); 819 | const channelName = "test-channel"; 820 | const event = "test-event"; 821 | 822 | const { result } = renderHook(() => 823 | echoModule.useEchoPresence(channelName, event, mockCallback), 824 | ); 825 | 826 | result.current.leaveChannel(); 827 | 828 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 829 | `presence-${channelName}`, 830 | ); 831 | }); 832 | 833 | it("can leave all channel variations", async () => { 834 | const mockCallback = vi.fn(); 835 | const channelName = "test-channel"; 836 | const event = "test-event"; 837 | 838 | const { result } = renderHook(() => 839 | echoModule.useEchoPresence(channelName, event, mockCallback), 840 | ); 841 | 842 | result.current.leave(); 843 | 844 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 845 | }); 846 | 847 | it("events and listeners are optional", async () => { 848 | const channelName = "test-channel"; 849 | 850 | const { result } = renderHook(() => 851 | echoModule.useEchoPresence(channelName), 852 | ); 853 | 854 | expect(result.current).toHaveProperty("channel"); 855 | expect(result.current.channel).not.toBeNull(); 856 | }); 857 | }); 858 | 859 | describe("useEchoNotification hook", async () => { 860 | let echoModule: typeof import("../src/hooks/use-echo"); 861 | let configModule: typeof import("../src/config/index"); 862 | let echoInstance: Echo<"null">; 863 | 864 | beforeEach(async () => { 865 | vi.resetModules(); 866 | 867 | echoInstance = new Echo({ 868 | broadcaster: "null", 869 | }); 870 | 871 | echoModule = await getEchoModule(); 872 | configModule = await getConfigModule(); 873 | 874 | configModule.configureEcho({ 875 | broadcaster: "null", 876 | }); 877 | }); 878 | 879 | afterEach(() => { 880 | vi.clearAllMocks(); 881 | }); 882 | 883 | it("subscribes to a private channel and listens for notifications", async () => { 884 | const mockCallback = vi.fn(); 885 | const channelName = "test-channel"; 886 | 887 | const { result } = renderHook(() => 888 | echoModule.useEchoNotification(channelName, mockCallback), 889 | ); 890 | 891 | expect(result.current).toHaveProperty("leaveChannel"); 892 | expect(typeof result.current.leave).toBe("function"); 893 | expect(result.current).toHaveProperty("leave"); 894 | expect(typeof result.current.leaveChannel).toBe("function"); 895 | expect(result.current).toHaveProperty("listen"); 896 | expect(typeof result.current.listen).toBe("function"); 897 | expect(result.current).toHaveProperty("stopListening"); 898 | expect(typeof result.current.stopListening).toBe("function"); 899 | }); 900 | 901 | it("sets up a notification listener on a channel", async () => { 902 | const mockCallback = vi.fn(); 903 | const channelName = "test-channel"; 904 | 905 | renderHook(() => 906 | echoModule.useEchoNotification(channelName, mockCallback), 907 | ); 908 | 909 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 910 | 911 | const channel = echoInstance.private(channelName); 912 | expect(channel.notification).toHaveBeenCalled(); 913 | }); 914 | 915 | it("handles notification filtering by event type", async () => { 916 | const mockCallback = vi.fn(); 917 | const channelName = "test-channel"; 918 | const eventType = "specific-type"; 919 | 920 | renderHook(() => 921 | echoModule.useEchoNotification( 922 | channelName, 923 | mockCallback, 924 | eventType, 925 | ), 926 | ); 927 | 928 | const channel = echoInstance.private(channelName); 929 | expect(channel.notification).toHaveBeenCalled(); 930 | 931 | const notificationCallback = vi.mocked(channel.notification).mock 932 | .calls[0][0]; 933 | 934 | const matchingNotification = { 935 | type: eventType, 936 | data: { message: "test" }, 937 | }; 938 | const nonMatchingNotification = { 939 | type: "other-type", 940 | data: { message: "test" }, 941 | }; 942 | 943 | notificationCallback(matchingNotification); 944 | notificationCallback(nonMatchingNotification); 945 | 946 | expect(mockCallback).toHaveBeenCalledWith(matchingNotification); 947 | expect(mockCallback).toHaveBeenCalledTimes(1); 948 | expect(mockCallback).not.toHaveBeenCalledWith(nonMatchingNotification); 949 | }); 950 | 951 | it("handles multiple notification event types", async () => { 952 | const mockCallback = vi.fn(); 953 | const channelName = "test-channel"; 954 | const events = ["type1", "type2"]; 955 | 956 | renderHook(() => 957 | echoModule.useEchoNotification(channelName, mockCallback, events), 958 | ); 959 | 960 | const channel = echoInstance.private(channelName); 961 | expect(channel.notification).toHaveBeenCalled(); 962 | 963 | const notificationCallback = vi.mocked(channel.notification).mock 964 | .calls[0][0]; 965 | 966 | const notification1 = { type: events[0], data: {} }; 967 | const notification2 = { type: events[1], data: {} }; 968 | const notification3 = { type: "type3", data: {} }; 969 | 970 | notificationCallback(notification1); 971 | notificationCallback(notification2); 972 | notificationCallback(notification3); 973 | 974 | expect(mockCallback).toHaveBeenCalledWith(notification1); 975 | expect(mockCallback).toHaveBeenCalledWith(notification2); 976 | expect(mockCallback).toHaveBeenCalledTimes(2); 977 | expect(mockCallback).not.toHaveBeenCalledWith(notification3); 978 | }); 979 | 980 | it("handles dotted and slashed notification event types", async () => { 981 | const mockCallback = vi.fn(); 982 | const channelName = "test-channel"; 983 | const events = [ 984 | "App.Notifications.First", 985 | "App\\Notifications\\Second", 986 | ]; 987 | 988 | renderHook(() => 989 | echoModule.useEchoNotification(channelName, mockCallback, events), 990 | ); 991 | 992 | const channel = echoInstance.private(channelName); 993 | expect(channel.notification).toHaveBeenCalled(); 994 | 995 | const notificationCallback = vi.mocked(channel.notification).mock 996 | .calls[0][0]; 997 | 998 | const notification1 = { type: "App\\Notifications\\First", data: {} }; 999 | const notification2 = { type: "App\\Notifications\\Second", data: {} }; 1000 | 1001 | notificationCallback(notification1); 1002 | notificationCallback(notification2); 1003 | 1004 | expect(mockCallback).toHaveBeenCalledWith(notification1); 1005 | expect(mockCallback).toHaveBeenCalledWith(notification2); 1006 | expect(mockCallback).toHaveBeenCalledTimes(2); 1007 | }); 1008 | 1009 | it("accepts all notifications when no event types specified", async () => { 1010 | const mockCallback = vi.fn(); 1011 | const channelName = "test-channel"; 1012 | 1013 | renderHook(() => 1014 | echoModule.useEchoNotification(channelName, mockCallback), 1015 | ); 1016 | 1017 | const channel = echoInstance.private(channelName); 1018 | expect(channel.notification).toHaveBeenCalled(); 1019 | 1020 | const notificationCallback = vi.mocked(channel.notification).mock 1021 | .calls[0][0]; 1022 | 1023 | const notification1 = { type: "type1", data: {} }; 1024 | const notification2 = { type: "type2", data: {} }; 1025 | 1026 | notificationCallback(notification1); 1027 | notificationCallback(notification2); 1028 | 1029 | expect(mockCallback).toHaveBeenCalledWith(notification1); 1030 | expect(mockCallback).toHaveBeenCalledWith(notification2); 1031 | 1032 | expect(mockCallback).toHaveBeenCalledTimes(2); 1033 | }); 1034 | 1035 | it("cleans up subscriptions on unmount", async () => { 1036 | const mockCallback = vi.fn(); 1037 | const channelName = "test-channel"; 1038 | 1039 | const { unmount } = renderHook(() => 1040 | echoModule.useEchoNotification(channelName, mockCallback), 1041 | ); 1042 | 1043 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 1044 | 1045 | expect(() => unmount()).not.toThrow(); 1046 | 1047 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 1048 | `private-${channelName}`, 1049 | ); 1050 | }); 1051 | 1052 | it("won't subscribe multiple times to the same channel", async () => { 1053 | const mockCallback = vi.fn(); 1054 | const channelName = "test-channel"; 1055 | 1056 | const { unmount: unmount1 } = renderHook(() => 1057 | echoModule.useEchoNotification(channelName, mockCallback), 1058 | ); 1059 | 1060 | const { unmount: unmount2 } = renderHook(() => 1061 | echoModule.useEchoNotification(channelName, mockCallback), 1062 | ); 1063 | 1064 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 1065 | 1066 | expect(() => unmount1()).not.toThrow(); 1067 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 1068 | 1069 | expect(() => unmount2()).not.toThrow(); 1070 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 1071 | `private-${channelName}`, 1072 | ); 1073 | }); 1074 | 1075 | it("can leave a channel", async () => { 1076 | const mockCallback = vi.fn(); 1077 | const channelName = "test-channel"; 1078 | 1079 | const { result } = renderHook(() => 1080 | echoModule.useEchoNotification(channelName, mockCallback), 1081 | ); 1082 | 1083 | result.current.leaveChannel(); 1084 | 1085 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 1086 | `private-${channelName}`, 1087 | ); 1088 | }); 1089 | 1090 | it("can leave all channel variations", async () => { 1091 | const mockCallback = vi.fn(); 1092 | const channelName = "test-channel"; 1093 | 1094 | const { result } = renderHook(() => 1095 | echoModule.useEchoNotification(channelName, mockCallback), 1096 | ); 1097 | 1098 | result.current.leave(); 1099 | 1100 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 1101 | }); 1102 | 1103 | it("can manually start and stop listening", async () => { 1104 | const mockCallback = vi.fn(); 1105 | const channelName = "test-channel"; 1106 | 1107 | const { result } = renderHook(() => 1108 | echoModule.useEchoNotification(channelName, mockCallback), 1109 | ); 1110 | 1111 | const channel = echoInstance.private(channelName); 1112 | expect(channel.notification).toHaveBeenCalledTimes(1); 1113 | 1114 | result.current.stopListening(); 1115 | result.current.listen(); 1116 | 1117 | expect(channel.notification).toHaveBeenCalledTimes(1); 1118 | }); 1119 | 1120 | it("stopListening prevents new notification listeners", async () => { 1121 | const mockCallback = vi.fn(); 1122 | const channelName = "test-channel"; 1123 | 1124 | const { result } = renderHook(() => 1125 | echoModule.useEchoNotification(channelName, mockCallback), 1126 | ); 1127 | 1128 | result.current.stopListening(); 1129 | 1130 | expect(result.current.stopListening).toBeDefined(); 1131 | expect(typeof result.current.stopListening).toBe("function"); 1132 | }); 1133 | 1134 | it("callback and events are optional", async () => { 1135 | const channelName = "test-channel"; 1136 | 1137 | const { result } = renderHook(() => 1138 | echoModule.useEchoNotification(channelName), 1139 | ); 1140 | 1141 | expect(result.current).toHaveProperty("channel"); 1142 | expect(result.current.channel).not.toBeNull(); 1143 | }); 1144 | }); 1145 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./dist", 5 | "emitDeclarationOnly": true, 6 | "module": "ES2020", 7 | "moduleResolution": "node", 8 | "outDir": "./dist", 9 | "sourceMap": false, 10 | "target": "ES2020", 11 | "verbatimModuleSyntax": true, 12 | "skipLibCheck": true, 13 | "importHelpers": true, 14 | "strictPropertyInitialization": false, 15 | "allowSyntheticDefaultImports": true, 16 | "isolatedModules": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "esModuleInterop": true, 20 | "strict": true, 21 | "typeRoots": ["node_modules/@types"], 22 | "lib": ["dom", "es2020"] 23 | }, 24 | "include": ["./typings/**/*.ts", "./src/**/*.ts", "./tests/**/*.ts"], 25 | "exclude": ["./node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig, PluginOption, UserConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | const handleEnvVariablesPlugin = (): PluginOption => { 6 | return { 7 | name: "handle-env-variables-plugin", 8 | generateBundle(options, bundle) { 9 | for (const fileName in bundle) { 10 | const file = bundle[fileName]; 11 | 12 | if (file.type === "chunk" && file.fileName.endsWith(".js")) { 13 | const transformedContent = file.code.replace( 14 | /import\.meta\.env\.VITE_([A-Z0-9_]+)/g, 15 | "(typeof import.meta.env !== 'undefined' ? import.meta.env.VITE_$1 : undefined)", 16 | ); 17 | 18 | file.code = transformedContent; 19 | } 20 | } 21 | }, 22 | }; 23 | }; 24 | 25 | const config: UserConfig = (() => { 26 | const common: Partial = { 27 | rollupOptions: { 28 | external: ["react", "pusher-js"], 29 | output: { 30 | globals: { 31 | react: "React", 32 | "pusher-js": "Pusher", 33 | }, 34 | }, 35 | }, 36 | outDir: resolve(__dirname, "dist"), 37 | sourcemap: true, 38 | minify: true, 39 | }; 40 | 41 | if (process.env.FORMAT === "iife") { 42 | return { 43 | build: { 44 | lib: { 45 | entry: resolve(__dirname, "src/index.iife.ts"), 46 | name: "EchoReact", 47 | formats: ["iife"], 48 | fileName: () => "echo-react.iife.js", 49 | }, 50 | emptyOutDir: false, // Don't empty the output directory for the second build 51 | ...common, 52 | }, 53 | }; 54 | } 55 | 56 | return { 57 | plugins: [ 58 | dts({ 59 | insertTypesEntry: true, 60 | rollupTypes: true, 61 | include: ["src/**/*.ts"], 62 | }), 63 | handleEnvVariablesPlugin(), 64 | ], 65 | define: { 66 | "import.meta.env.VITE_REVERB_APP_KEY": 67 | "import.meta.env.VITE_REVERB_APP_KEY", 68 | "import.meta.env.VITE_REVERB_HOST": 69 | "import.meta.env.VITE_REVERB_HOST", 70 | "import.meta.env.VITE_REVERB_PORT": 71 | "import.meta.env.VITE_REVERB_PORT", 72 | "import.meta.env.VITE_REVERB_SCHEME": 73 | "import.meta.env.VITE_REVERB_SCHEME", 74 | "import.meta.env.VITE_PUSHER_APP_KEY": 75 | "import.meta.env.VITE_PUSHER_APP_KEY", 76 | "import.meta.env.VITE_PUSHER_APP_CLUSTER": 77 | "import.meta.env.VITE_PUSHER_APP_CLUSTER", 78 | "import.meta.env.VITE_PUSHER_HOST": 79 | "import.meta.env.VITE_PUSHER_HOST", 80 | "import.meta.env.VITE_PUSHER_PORT": 81 | "import.meta.env.VITE_PUSHER_PORT", 82 | "import.meta.env.VITE_SOCKET_IO_HOST": 83 | "import.meta.env.VITE_SOCKET_IO_HOST", 84 | "import.meta.env.VITE_ABLY_PUBLIC_KEY": 85 | "import.meta.env.VITE_ABLY_PUBLIC_KEY", 86 | }, 87 | build: { 88 | lib: { 89 | entry: resolve(__dirname, "src/index.ts"), 90 | formats: ["es", "cjs"], 91 | fileName: (format, entryName) => { 92 | return `${entryName}.${format === "es" ? "js" : "common.js"}`; 93 | }, 94 | }, 95 | emptyOutDir: true, 96 | ...common, 97 | }, 98 | test: { 99 | globals: true, 100 | environment: "jsdom", 101 | }, 102 | }; 103 | })(); 104 | 105 | export default defineConfig(config); 106 | -------------------------------------------------------------------------------- /packages/vue/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Echo Vue Helpers 2 | 3 | ## `configureEcho` 4 | 5 | You must call this function somewhere in your app _before_ you use `useEcho` in a component to configure your Echo instance. You only need to pass the required data: 6 | 7 | ```ts 8 | import { configureEcho } from "@laravel/echo-vue"; 9 | 10 | configureEcho({ 11 | broadcaster: "reverb", 12 | }); 13 | ``` 14 | 15 | Based on your brodcaster, the package will fill in appropriate defaults for the rest of the config [based on the Echo documentation](https://laravel.com/docs/broadcasting#client-side-installation). You can always override these values by simply passing in your own. 16 | 17 | In the above example, the configuration would also fill in the following keys if they aren't present: 18 | 19 | ```ts 20 | { 21 | key: import.meta.env.VITE_REVERB_APP_KEY, 22 | wsHost: import.meta.env.VITE_REVERB_HOST, 23 | wsPort: import.meta.env.VITE_REVERB_PORT, 24 | wssPort: import.meta.env.VITE_REVERB_PORT, 25 | forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', 26 | enabledTransports: ['ws', 'wss'], 27 | } 28 | ``` 29 | 30 | ## `useEcho` Hook 31 | 32 | Connect to private channel: 33 | 34 | ```ts 35 | import { useEcho } from "@laravel/echo-vue"; 36 | 37 | const { leaveChannel, leave, stopListening, listen } = useEcho( 38 | `orders.${orderId}`, 39 | "OrderShipmentStatusUpdated", 40 | (e) => { 41 | console.log(e.order); 42 | }, 43 | ); 44 | 45 | // Stop listening without leaving channel 46 | stopListening(); 47 | 48 | // Start listening again 49 | listen(); 50 | 51 | // Leave channel 52 | leaveChannel(); 53 | 54 | // Leave a channel and also its associated private and presence channels 55 | leave(); 56 | ``` 57 | 58 | Multiple events: 59 | 60 | ```ts 61 | useEcho( 62 | `orders.${orderId}`, 63 | ["OrderShipmentStatusUpdated", "OrderShipped"], 64 | (e) => { 65 | console.log(e.order); 66 | }, 67 | ); 68 | ``` 69 | 70 | Specify shape of payload data: 71 | 72 | ```ts 73 | type OrderData = { 74 | order: { 75 | id: number; 76 | user: { 77 | id: number; 78 | name: string; 79 | }; 80 | created_at: string; 81 | }; 82 | }; 83 | 84 | useEcho(`orders.${orderId}`, "OrderShipmentStatusUpdated", (e) => { 85 | console.log(e.order.id); 86 | console.log(e.order.user.id); 87 | }); 88 | ``` 89 | 90 | Connect to public channel: 91 | 92 | ```ts 93 | useEchoPublic("posts", "PostPublished", (e) => { 94 | console.log(e.post); 95 | }); 96 | ``` 97 | 98 | Connect to presence channel: 99 | 100 | ```ts 101 | useEchoPresence("posts", "PostPublished", (e) => { 102 | console.log(e.post); 103 | }); 104 | ``` 105 | 106 | Listening for model events: 107 | 108 | ```ts 109 | useEchoModel("App.Models.User", userId, ["UserCreated", "UserUpdated"], (e) => { 110 | console.log(e.model); 111 | }); 112 | ``` 113 | -------------------------------------------------------------------------------- /packages/vue/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | 4 | const config = [ 5 | { 6 | ignores: ["dist/**/*"], 7 | files: ["src/**/*.ts"], 8 | languageOptions: { 9 | parser: tsParser, // Use the imported parser object 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | project: "./tsconfig.json", // Path to your TypeScript configuration file 14 | }, 15 | }, 16 | plugins: { 17 | "@typescript-eslint": tsPlugin, 18 | }, 19 | rules: { 20 | ...tsPlugin.configs.recommended.rules, 21 | ...tsPlugin.configs["recommended-requiring-type-checking"].rules, 22 | "@typescript-eslint/ban-types": "off", 23 | "@typescript-eslint/no-empty-object-type": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/no-floating-promises": "error", 26 | "@typescript-eslint/no-unsafe-argument": "warn", 27 | "@typescript-eslint/no-unsafe-assignment": "warn", 28 | "@typescript-eslint/no-unsafe-call": "warn", 29 | "@typescript-eslint/no-unsafe-function-type": "off", 30 | "@typescript-eslint/no-unsafe-member-access": "warn", 31 | "@typescript-eslint/no-unsafe-return": "warn", 32 | "@typescript-eslint/no-unused-vars": [ 33 | "warn", 34 | { argsIgnorePattern: "^_" }, 35 | ], 36 | "no-console": "warn", 37 | "prefer-const": "off", 38 | }, 39 | }, 40 | ]; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laravel/echo-vue", 3 | "version": "2.1.5", 4 | "description": "Vue hooks for seamless integration with Laravel Echo.", 5 | "keywords": [ 6 | "laravel", 7 | "pusher", 8 | "ably", 9 | "vue" 10 | ], 11 | "homepage": "https://github.com/laravel/echo/tree/2.x/packages/vue", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/laravel/echo" 15 | }, 16 | "license": "MIT", 17 | "author": { 18 | "name": "Taylor Otwell" 19 | }, 20 | "type": "module", 21 | "main": "dist/index.common.js", 22 | "module": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "scripts": { 25 | "build": "vite build && FORMAT=iife vite build", 26 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 27 | "prepublish": "pnpm run build", 28 | "release": "vitest --run && git push --follow-tags && pnpm publish", 29 | "test": "vitest", 30 | "format": "prettier --write ." 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.26.7", 34 | "@babel/plugin-proposal-decorators": "^7.25.9", 35 | "@babel/plugin-proposal-function-sent": "^7.25.9", 36 | "@babel/plugin-proposal-throw-expressions": "^7.25.9", 37 | "@babel/plugin-transform-export-namespace-from": "^7.25.9", 38 | "@babel/plugin-transform-numeric-separator": "^7.25.9", 39 | "@babel/plugin-transform-object-assign": "^7.25.9", 40 | "@babel/preset-env": "^7.26.7", 41 | "@testing-library/vue": "^8.1.0", 42 | "@types/node": "^22.15.3", 43 | "@typescript-eslint/eslint-plugin": "^8.21.0", 44 | "@typescript-eslint/parser": "^8.21.0", 45 | "@vue/test-utils": "^2.4.6", 46 | "eslint": "^9.0.0", 47 | "laravel-echo": "workspace:^", 48 | "prettier": "^3.5.3", 49 | "pusher-js": "^8.0", 50 | "socket.io-client": "^4.0", 51 | "tslib": "^2.8.1", 52 | "typescript": "^5.7.0", 53 | "vite": "^6.3.3", 54 | "vite-plugin-dts": "^4.5.3", 55 | "vitest": "^3.1.2" 56 | }, 57 | "peerDependencies": { 58 | "pusher-js": "*", 59 | "socket.io-client": "*", 60 | "vue": "^3.0.0" 61 | }, 62 | "typesVersions": { 63 | "*": { 64 | "socket.io-client": [], 65 | "pusher-js": [] 66 | } 67 | }, 68 | "engines": { 69 | "node": ">=20" 70 | }, 71 | "exports": { 72 | ".": { 73 | "types": "./dist/index.d.ts", 74 | "import": "./dist/index.js", 75 | "require": "./dist/index.common.js" 76 | } 77 | }, 78 | "overrides": { 79 | "glob": "^9.0.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/vue/src/composables/useEcho.ts: -------------------------------------------------------------------------------- 1 | import { type BroadcastDriver } from "laravel-echo"; 2 | import { onMounted, onUnmounted, ref, watch } from "vue"; 3 | import { echo } from "../config"; 4 | import type { 5 | BroadcastNotification, 6 | Channel, 7 | ChannelData, 8 | ChannelReturnType, 9 | Connection, 10 | ModelEvents, 11 | ModelPayload, 12 | } from "../types"; 13 | import { toArray } from "../util"; 14 | 15 | const channels: Record> = {}; 16 | 17 | const resolveChannelSubscription = ( 18 | channel: Channel, 19 | ): Connection => { 20 | if (channels[channel.id]) { 21 | channels[channel.id].count += 1; 22 | 23 | return channels[channel.id].connection; 24 | } 25 | 26 | const channelSubscription = subscribeToChannel(channel); 27 | 28 | channels[channel.id] = { 29 | count: 1, 30 | connection: channelSubscription, 31 | }; 32 | 33 | return channelSubscription; 34 | }; 35 | 36 | const subscribeToChannel = ( 37 | channel: Channel, 38 | ): Connection => { 39 | const instance = echo(); 40 | 41 | if (channel.visibility === "presence") { 42 | return instance.join(channel.name); 43 | } 44 | 45 | if (channel.visibility === "private") { 46 | return instance.private(channel.name); 47 | } 48 | 49 | return instance.channel(channel.name); 50 | }; 51 | 52 | const leaveChannel = (channel: Channel, leaveAll: boolean = false): void => { 53 | if (!channels[channel.id]) { 54 | return; 55 | } 56 | 57 | channels[channel.id].count -= 1; 58 | 59 | if (channels[channel.id].count > 0) { 60 | return; 61 | } 62 | 63 | delete channels[channel.id]; 64 | 65 | if (leaveAll) { 66 | echo().leave(channel.name); 67 | } else { 68 | echo().leaveChannel(channel.id); 69 | } 70 | }; 71 | 72 | export const useEcho = < 73 | TPayload, 74 | TDriver extends BroadcastDriver = BroadcastDriver, 75 | TVisibility extends Channel["visibility"] = "private", 76 | >( 77 | channelName: string, 78 | event: string | string[] = [], 79 | callback: (payload: TPayload) => void = () => {}, 80 | dependencies: any[] = [], 81 | visibility: TVisibility = "private" as TVisibility, 82 | ) => { 83 | const eventCallback = ref(callback); 84 | const listening = ref(false); 85 | 86 | watch( 87 | () => callback, 88 | (newCallback) => { 89 | eventCallback.value = newCallback; 90 | }, 91 | ); 92 | 93 | const channel: Channel = { 94 | name: channelName, 95 | id: ["private", "presence"].includes(visibility) 96 | ? `${visibility}-${channelName}` 97 | : channelName, 98 | visibility, 99 | }; 100 | 101 | const subscription: Connection = 102 | resolveChannelSubscription(channel); 103 | const events = Array.isArray(event) ? event : [event]; 104 | 105 | const setupSubscription = () => { 106 | listen(); 107 | }; 108 | 109 | const listen = () => { 110 | if (listening.value) { 111 | return; 112 | } 113 | 114 | events.forEach((e) => { 115 | subscription.listen(e, eventCallback.value); 116 | }); 117 | 118 | listening.value = true; 119 | }; 120 | 121 | const stopListening = () => { 122 | if (!listening.value) { 123 | return; 124 | } 125 | 126 | events.forEach((e) => { 127 | subscription.stopListening(e, eventCallback.value); 128 | }); 129 | 130 | listening.value = false; 131 | }; 132 | 133 | const tearDown = (leaveAll: boolean = false) => { 134 | stopListening(); 135 | leaveChannel(channel, leaveAll); 136 | }; 137 | 138 | onMounted(() => { 139 | setupSubscription(); 140 | }); 141 | 142 | onUnmounted(() => { 143 | tearDown(); 144 | }); 145 | 146 | if (dependencies.length > 0) { 147 | watch( 148 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 149 | () => dependencies, 150 | () => { 151 | tearDown(); 152 | setupSubscription(); 153 | }, 154 | { deep: true }, 155 | ); 156 | } 157 | 158 | return { 159 | /** 160 | * Leave the channel 161 | */ 162 | leaveChannel: tearDown, 163 | /** 164 | * Leave the channel and also its associated private and presence channels 165 | */ 166 | leave: () => tearDown(true), 167 | /** 168 | * Stop listening for event(s) without leaving the channel 169 | */ 170 | stopListening, 171 | /** 172 | * Listen for event(s) 173 | */ 174 | listen, 175 | /** 176 | * Channel instance 177 | */ 178 | channel: () => subscription as ChannelReturnType, 179 | }; 180 | }; 181 | 182 | export const useEchoNotification = < 183 | TPayload, 184 | TDriver extends BroadcastDriver = BroadcastDriver, 185 | >( 186 | channelName: string, 187 | callback: (payload: BroadcastNotification) => void = () => {}, 188 | event: string | string[] = [], 189 | dependencies: any[] = [], 190 | ) => { 191 | const result = useEcho, TDriver, "private">( 192 | channelName, 193 | [], 194 | callback, 195 | dependencies, 196 | "private", 197 | ); 198 | 199 | const events = toArray(event) 200 | .map((e) => { 201 | if (e.includes(".")) { 202 | return [e, e.replace(/\./g, "\\")]; 203 | } 204 | 205 | return [e, e.replace(/\\/g, ".")]; 206 | }) 207 | .flat(); 208 | 209 | const listening = ref(false); 210 | const initialized = ref(false); 211 | 212 | const cb = (notification: BroadcastNotification) => { 213 | if (!listening.value) { 214 | return; 215 | } 216 | 217 | if (events.length === 0 || events.includes(notification.type)) { 218 | callback(notification); 219 | } 220 | }; 221 | 222 | const listen = () => { 223 | if (listening.value) { 224 | return; 225 | } 226 | 227 | if (!initialized.value) { 228 | result.channel().notification(cb); 229 | } 230 | 231 | listening.value = true; 232 | initialized.value = true; 233 | }; 234 | 235 | const stopListening = () => { 236 | if (!listening.value) { 237 | return; 238 | } 239 | 240 | listening.value = false; 241 | }; 242 | 243 | onMounted(() => { 244 | listen(); 245 | }); 246 | 247 | return { 248 | ...result, 249 | /** 250 | * Stop listening for notification events 251 | */ 252 | stopListening, 253 | /** 254 | * Listen for notification events 255 | */ 256 | listen, 257 | }; 258 | }; 259 | 260 | export const useEchoPresence = < 261 | TPayload, 262 | TDriver extends BroadcastDriver = BroadcastDriver, 263 | >( 264 | channelName: string, 265 | event: string | string[] = [], 266 | callback: (payload: TPayload) => void = () => {}, 267 | dependencies: any[] = [], 268 | ) => { 269 | return useEcho( 270 | channelName, 271 | event, 272 | callback, 273 | dependencies, 274 | "presence", 275 | ); 276 | }; 277 | 278 | export const useEchoPublic = < 279 | TPayload, 280 | TDriver extends BroadcastDriver = BroadcastDriver, 281 | >( 282 | channelName: string, 283 | event: string | string[] = [], 284 | callback: (payload: TPayload) => void = () => {}, 285 | dependencies: any[] = [], 286 | ) => { 287 | return useEcho( 288 | channelName, 289 | event, 290 | callback, 291 | dependencies, 292 | "public", 293 | ); 294 | }; 295 | 296 | export const useEchoModel = < 297 | TPayload, 298 | TModel extends string, 299 | TDriver extends BroadcastDriver = BroadcastDriver, 300 | >( 301 | model: TModel, 302 | identifier: string | number, 303 | event: ModelEvents | ModelEvents[] = [], 304 | callback: (payload: ModelPayload) => void = () => {}, 305 | dependencies: any[] = [], 306 | ) => { 307 | return useEcho, TDriver, "private">( 308 | `${model}.${identifier}`, 309 | toArray(event).map((e) => (e.startsWith(".") ? e : `.${e}`)), 310 | callback, 311 | dependencies, 312 | "private", 313 | ); 314 | }; 315 | -------------------------------------------------------------------------------- /packages/vue/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import Echo, { type BroadcastDriver, type EchoOptions } from "laravel-echo"; 2 | import Pusher from "pusher-js"; 3 | import type { ConfigDefaults } from "../types"; 4 | 5 | let echoInstance: Echo | null = null; 6 | let echoConfig: EchoOptions | null = null; 7 | 8 | const getEchoInstance = (): Echo => { 9 | if (echoInstance) { 10 | return echoInstance as Echo; 11 | } 12 | 13 | if (!echoConfig) { 14 | throw new Error( 15 | "Echo has not been configured. Please call `configureEcho()` with your configuration options before using Echo.", 16 | ); 17 | } 18 | 19 | echoConfig.Pusher ??= Pusher; 20 | 21 | echoInstance = new Echo(echoConfig); 22 | 23 | return echoInstance as Echo; 24 | }; 25 | 26 | /** 27 | * Configure the Echo instance with sensible defaults. 28 | * 29 | * @link https://laravel.com/docs/broadcasting#client-side-installation 30 | */ 31 | export const configureEcho = ( 32 | config: EchoOptions, 33 | ): void => { 34 | const defaults: ConfigDefaults = { 35 | reverb: { 36 | broadcaster: "reverb", 37 | key: import.meta.env.VITE_REVERB_APP_KEY, 38 | wsHost: import.meta.env.VITE_REVERB_HOST, 39 | wsPort: import.meta.env.VITE_REVERB_PORT, 40 | wssPort: import.meta.env.VITE_REVERB_PORT, 41 | forceTLS: 42 | (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https", 43 | enabledTransports: ["ws", "wss"], 44 | }, 45 | pusher: { 46 | broadcaster: "pusher", 47 | key: import.meta.env.VITE_PUSHER_APP_KEY, 48 | cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, 49 | forceTLS: true, 50 | wsHost: import.meta.env.VITE_PUSHER_HOST, 51 | wsPort: import.meta.env.VITE_PUSHER_PORT, 52 | wssPort: import.meta.env.VITE_PUSHER_PORT, 53 | enabledTransports: ["ws", "wss"], 54 | }, 55 | "socket.io": { 56 | broadcaster: "socket.io", 57 | host: import.meta.env.VITE_SOCKET_IO_HOST, 58 | }, 59 | null: { 60 | broadcaster: "null", 61 | }, 62 | ably: { 63 | broadcaster: "pusher", 64 | key: import.meta.env.VITE_ABLY_PUBLIC_KEY, 65 | wsHost: "realtime-pusher.ably.io", 66 | wsPort: 443, 67 | disableStats: true, 68 | encrypted: true, 69 | }, 70 | }; 71 | 72 | echoConfig = { 73 | ...defaults[config.broadcaster], 74 | ...config, 75 | } as EchoOptions; 76 | 77 | // Reset the instance if it was already created 78 | if (echoInstance) { 79 | echoInstance = null; 80 | } 81 | }; 82 | 83 | export const echo = (): Echo => 84 | getEchoInstance(); 85 | -------------------------------------------------------------------------------- /packages/vue/src/index.iife.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useEcho, 3 | useEchoModel, 4 | useEchoPresence, 5 | useEchoPublic, 6 | } from "./composables/useEcho"; 7 | export { configureEcho, echo } from "./config/index"; 8 | -------------------------------------------------------------------------------- /packages/vue/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useEcho, 3 | useEchoModel, 4 | useEchoNotification, 5 | useEchoPresence, 6 | useEchoPublic, 7 | } from "./composables/useEcho"; 8 | export { configureEcho, echo } from "./config/index"; 9 | -------------------------------------------------------------------------------- /packages/vue/src/types.ts: -------------------------------------------------------------------------------- 1 | import { type BroadcastDriver, type Broadcaster } from "laravel-echo"; 2 | 3 | export type Connection = 4 | | Broadcaster[T]["public"] 5 | | Broadcaster[T]["private"] 6 | | Broadcaster[T]["presence"]; 7 | 8 | export type ChannelData = { 9 | count: number; 10 | connection: Connection; 11 | }; 12 | 13 | export type Channel = { 14 | name: string; 15 | id: string; 16 | visibility: "private" | "public" | "presence"; 17 | }; 18 | 19 | export type BroadcastNotification = TPayload & { 20 | id: string; 21 | type: string; 22 | }; 23 | 24 | export type ChannelReturnType< 25 | T extends BroadcastDriver, 26 | V extends Channel["visibility"], 27 | > = V extends "presence" 28 | ? Broadcaster[T]["presence"] 29 | : V extends "private" 30 | ? Broadcaster[T]["private"] 31 | : Broadcaster[T]["public"]; 32 | 33 | export type ConfigDefaults = Record< 34 | O, 35 | Broadcaster[O]["options"] 36 | >; 37 | 38 | export type ModelPayload = { 39 | model: T; 40 | connection: string | null; 41 | queue: string | null; 42 | afterCommit: boolean; 43 | }; 44 | 45 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 46 | export type ModelName = T extends `${infer _}.${infer U}` 47 | ? ModelName 48 | : T; 49 | 50 | type ModelEvent = 51 | | "Retrieved" 52 | | "Creating" 53 | | "Created" 54 | | "Updating" 55 | | "Updated" 56 | | "Saving" 57 | | "Saved" 58 | | "Deleting" 59 | | "Deleted" 60 | | "Trashed" 61 | | "ForceDeleting" 62 | | "ForceDeleted" 63 | | "Restoring" 64 | | "Restored" 65 | | "Replicating"; 66 | 67 | export type ModelEvents = 68 | | `.${ModelName}${ModelEvent}` 69 | | `${ModelName}${ModelEvent}`; 70 | -------------------------------------------------------------------------------- /packages/vue/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export const toArray = (item: T | T[]): T[] => 2 | Array.isArray(item) ? item : [item]; 3 | -------------------------------------------------------------------------------- /packages/vue/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_PUSHER_APP_CLUSTER: string; 5 | readonly VITE_PUSHER_APP_KEY: string; 6 | readonly VITE_PUSHER_HOST: string; 7 | readonly VITE_PUSHER_PORT: number; 8 | 9 | readonly VITE_REVERB_HOST: string; 10 | readonly VITE_REVERB_APP_KEY: string; 11 | readonly VITE_REVERB_PORT: number; 12 | readonly VITE_REVERB_SCHEME: string; 13 | 14 | readonly VITE_SOCKET_IO_HOST: string; 15 | 16 | readonly VITE_ABLY_PUBLIC_KEY: string; 17 | } 18 | 19 | interface ImportMeta { 20 | readonly env: ImportMetaEnv; 21 | } 22 | -------------------------------------------------------------------------------- /packages/vue/tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 | import { configureEcho, echo } from "../src/config"; 3 | 4 | describe("echo helper", async () => { 5 | beforeEach(() => { 6 | vi.resetModules(); 7 | }); 8 | 9 | afterEach(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | it("throws error when Echo is not configured", async () => { 14 | expect(() => echo()).toThrow("Echo has not been configured"); 15 | }); 16 | 17 | it("creates Echo instance with proper configuration", async () => { 18 | configureEcho({ 19 | broadcaster: "null", 20 | }); 21 | 22 | expect(echo()).toBeDefined(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/vue/tests/useEcho.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import Echo from "laravel-echo"; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 | import { defineComponent } from "vue"; 5 | import { 6 | useEcho, 7 | useEchoNotification, 8 | useEchoPresence, 9 | useEchoPublic, 10 | } from "../src/composables/useEcho"; 11 | import { configureEcho } from "../src/config/index"; 12 | 13 | const getUnConfiguredTestComponent = ( 14 | channelName: string, 15 | event: string | string[], 16 | callback: (data: any) => void, 17 | visibility: "private" | "public" = "private", 18 | ) => { 19 | const TestComponent = defineComponent({ 20 | setup() { 21 | return { 22 | ...useEcho(channelName, event, callback, [], visibility), 23 | }; 24 | }, 25 | template: "
", 26 | }); 27 | 28 | return mount(TestComponent); 29 | }; 30 | 31 | const getTestComponent = ( 32 | channelName: string, 33 | event: string | string[] | undefined, 34 | callback: ((data: any) => void) | undefined, 35 | dependencies: any[] = [], 36 | visibility: "private" | "public" = "private", 37 | ) => { 38 | const TestComponent = defineComponent({ 39 | setup() { 40 | configureEcho({ 41 | broadcaster: "null", 42 | }); 43 | 44 | return { 45 | ...useEcho( 46 | channelName, 47 | event, 48 | callback, 49 | dependencies, 50 | visibility, 51 | ), 52 | }; 53 | }, 54 | template: "
", 55 | }); 56 | 57 | return mount(TestComponent); 58 | }; 59 | 60 | const getPublicTestComponent = ( 61 | channelName: string, 62 | event: string | string[] | undefined, 63 | callback: ((data: any) => void) | undefined, 64 | dependencies: any[] = [], 65 | ) => { 66 | const TestComponent = defineComponent({ 67 | setup() { 68 | configureEcho({ 69 | broadcaster: "null", 70 | }); 71 | 72 | return { 73 | ...useEchoPublic(channelName, event, callback, dependencies), 74 | }; 75 | }, 76 | template: "
", 77 | }); 78 | 79 | return mount(TestComponent); 80 | }; 81 | 82 | const getPresenceTestComponent = ( 83 | channelName: string, 84 | event: string | string[] | undefined, 85 | callback: ((data: any) => void) | undefined, 86 | dependencies: any[] = [], 87 | ) => { 88 | const TestComponent = defineComponent({ 89 | setup() { 90 | configureEcho({ 91 | broadcaster: "null", 92 | }); 93 | 94 | return { 95 | ...useEchoPresence(channelName, event, callback, dependencies), 96 | }; 97 | }, 98 | template: "
", 99 | }); 100 | 101 | return mount(TestComponent); 102 | }; 103 | 104 | const getNotificationTestComponent = ( 105 | channelName: string, 106 | callback: ((data: any) => void) | undefined, 107 | event: string | string[] | undefined, 108 | dependencies: any[] = [], 109 | ) => { 110 | const TestComponent = defineComponent({ 111 | setup() { 112 | configureEcho({ 113 | broadcaster: "null", 114 | }); 115 | 116 | return { 117 | ...useEchoNotification( 118 | channelName, 119 | callback, 120 | event, 121 | dependencies, 122 | ), 123 | }; 124 | }, 125 | template: "
", 126 | }); 127 | 128 | return mount(TestComponent); 129 | }; 130 | 131 | vi.mock("laravel-echo", () => { 132 | const mockPrivateChannel = { 133 | leaveChannel: vi.fn(), 134 | listen: vi.fn(), 135 | stopListening: vi.fn(), 136 | notification: vi.fn(), 137 | }; 138 | 139 | const mockPublicChannel = { 140 | leaveChannel: vi.fn(), 141 | listen: vi.fn(), 142 | stopListening: vi.fn(), 143 | }; 144 | 145 | const mockPresenceChannel = { 146 | leaveChannel: vi.fn(), 147 | listen: vi.fn(), 148 | stopListening: vi.fn(), 149 | here: vi.fn(), 150 | joining: vi.fn(), 151 | leaving: vi.fn(), 152 | whisper: vi.fn(), 153 | }; 154 | 155 | const Echo = vi.fn(); 156 | 157 | Echo.prototype.private = vi.fn(() => mockPrivateChannel); 158 | Echo.prototype.channel = vi.fn(() => mockPublicChannel); 159 | Echo.prototype.encryptedPrivate = vi.fn(); 160 | Echo.prototype.listen = vi.fn(); 161 | Echo.prototype.leave = vi.fn(); 162 | Echo.prototype.leaveChannel = vi.fn(); 163 | Echo.prototype.leaveAllChannels = vi.fn(); 164 | Echo.prototype.join = vi.fn(() => mockPresenceChannel); 165 | 166 | return { default: Echo }; 167 | }); 168 | 169 | describe("without echo configured", async () => { 170 | beforeEach(() => { 171 | vi.resetModules(); 172 | }); 173 | 174 | afterEach(() => { 175 | vi.clearAllMocks(); 176 | }); 177 | 178 | it("throws error when Echo is not configured", async () => { 179 | const mockCallback = vi.fn(); 180 | const channelName = "test-channel"; 181 | const event = "test-event"; 182 | 183 | expect(() => 184 | getUnConfiguredTestComponent( 185 | channelName, 186 | event, 187 | mockCallback, 188 | "private", 189 | ), 190 | ).toThrow("Echo has not been configured"); 191 | }); 192 | }); 193 | 194 | describe("useEcho hook", async () => { 195 | let echoInstance: Echo<"null">; 196 | let wrapper: ReturnType; 197 | 198 | beforeEach(async () => { 199 | vi.resetModules(); 200 | 201 | echoInstance = new Echo({ 202 | broadcaster: "null", 203 | }); 204 | }); 205 | 206 | afterEach(() => { 207 | wrapper.unmount(); 208 | vi.clearAllMocks(); 209 | }); 210 | 211 | it("subscribes to a channel and listens for events", async () => { 212 | const mockCallback = vi.fn(); 213 | const channelName = "test-channel"; 214 | const event = "test-event"; 215 | 216 | wrapper = getTestComponent(channelName, event, mockCallback); 217 | 218 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 219 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 220 | 221 | expect(wrapper.vm).toHaveProperty("leave"); 222 | expect(typeof wrapper.vm.leave).toBe("function"); 223 | }); 224 | 225 | it("handles multiple events", async () => { 226 | const mockCallback = vi.fn(); 227 | const channelName = "test-channel"; 228 | const events = ["event1", "event2"]; 229 | 230 | wrapper = getTestComponent(channelName, events, mockCallback); 231 | 232 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 233 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 234 | 235 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 236 | 237 | const channel = echoInstance.private(channelName); 238 | 239 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 240 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 241 | 242 | wrapper.unmount(); 243 | 244 | expect(channel.stopListening).toHaveBeenCalledWith( 245 | events[0], 246 | mockCallback, 247 | ); 248 | expect(channel.stopListening).toHaveBeenCalledWith( 249 | events[1], 250 | mockCallback, 251 | ); 252 | }); 253 | 254 | it("cleans up subscriptions on unmount", async () => { 255 | const mockCallback = vi.fn(); 256 | const channelName = "test-channel"; 257 | const event = "test-event"; 258 | 259 | wrapper = getTestComponent(channelName, event, mockCallback); 260 | 261 | expect(echoInstance.private).toHaveBeenCalled(); 262 | 263 | wrapper.unmount(); 264 | 265 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 266 | }); 267 | 268 | it("won't subscribe multiple times to the same channel", async () => { 269 | const mockCallback = vi.fn(); 270 | const channelName = "test-channel"; 271 | const event = "test-event"; 272 | 273 | wrapper = getTestComponent(channelName, event, mockCallback); 274 | 275 | const wrapper2 = getTestComponent(channelName, event, mockCallback); 276 | 277 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 278 | 279 | wrapper.unmount(); 280 | 281 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 282 | 283 | wrapper2.unmount(); 284 | 285 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 286 | }); 287 | 288 | it("will register callbacks for events", async () => { 289 | const mockCallback = vi.fn(); 290 | const channelName = "test-channel"; 291 | const event = "test-event"; 292 | 293 | wrapper = getTestComponent(channelName, event, mockCallback); 294 | 295 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 296 | 297 | expect(echoInstance.private(channelName).listen).toHaveBeenCalledWith( 298 | event, 299 | mockCallback, 300 | ); 301 | }); 302 | 303 | it("can leave a channel", async () => { 304 | const mockCallback = vi.fn(); 305 | const channelName = "test-channel"; 306 | const event = "test-event"; 307 | 308 | wrapper = getTestComponent(channelName, event, mockCallback); 309 | 310 | wrapper.vm.leaveChannel(); 311 | 312 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 313 | "private-" + channelName, 314 | ); 315 | }); 316 | 317 | it("can leave all channel variations", async () => { 318 | const mockCallback = vi.fn(); 319 | const channelName = "test-channel"; 320 | const event = "test-event"; 321 | 322 | wrapper = getTestComponent(channelName, event, mockCallback); 323 | 324 | wrapper.vm.leave(); 325 | 326 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 327 | }); 328 | 329 | it("can connect to a public channel", async () => { 330 | const mockCallback = vi.fn(); 331 | const channelName = "test-channel"; 332 | const event = "test-event"; 333 | 334 | wrapper = getTestComponent( 335 | channelName, 336 | event, 337 | mockCallback, 338 | [], 339 | "public", 340 | ); 341 | 342 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 343 | 344 | wrapper.vm.leaveChannel(); 345 | 346 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 347 | }); 348 | 349 | it("listen method adds event listeners", async () => { 350 | const mockCallback = vi.fn(); 351 | const channelName = "test-channel"; 352 | const event = "test-event"; 353 | 354 | wrapper = getTestComponent(channelName, event, mockCallback); 355 | const mockChannel = echoInstance.private(channelName); 356 | 357 | expect(mockChannel.listen).toHaveBeenCalledWith(event, mockCallback); 358 | 359 | wrapper.vm.stopListening(); 360 | 361 | expect(mockChannel.stopListening).toHaveBeenCalledWith( 362 | event, 363 | mockCallback, 364 | ); 365 | 366 | wrapper.vm.listen(); 367 | 368 | expect(mockChannel.listen).toHaveBeenCalledWith(event, mockCallback); 369 | }); 370 | 371 | it("listen method is a no-op when already listening", async () => { 372 | const mockCallback = vi.fn(); 373 | const channelName = "test-channel"; 374 | const event = "test-event"; 375 | 376 | wrapper = getTestComponent(channelName, event, mockCallback); 377 | const mockChannel = echoInstance.private(channelName); 378 | 379 | wrapper.vm.listen(); 380 | 381 | expect(mockChannel.listen).toHaveBeenCalledTimes(1); 382 | }); 383 | 384 | it("stopListening method removes event listeners", async () => { 385 | const mockCallback = vi.fn(); 386 | const channelName = "test-channel"; 387 | const event = "test-event"; 388 | 389 | wrapper = getTestComponent(channelName, event, mockCallback); 390 | const mockChannel = echoInstance.private(channelName); 391 | 392 | wrapper.vm.stopListening(); 393 | 394 | expect(mockChannel.stopListening).toHaveBeenCalledWith( 395 | event, 396 | mockCallback, 397 | ); 398 | }); 399 | 400 | it("stopListening method is a no-op when not listening", async () => { 401 | const mockCallback = vi.fn(); 402 | const channelName = "test-channel"; 403 | const event = "test-event"; 404 | 405 | wrapper = getTestComponent(channelName, event, mockCallback); 406 | const mockChannel = echoInstance.private(channelName); 407 | 408 | wrapper.vm.stopListening(); 409 | wrapper.vm.stopListening(); 410 | 411 | expect(mockChannel.stopListening).toHaveBeenCalledTimes(1); 412 | }); 413 | 414 | it("listen and stopListening work with multiple events", async () => { 415 | const mockCallback = vi.fn(); 416 | const channelName = "test-channel"; 417 | const events = ["event1", "event2"]; 418 | 419 | wrapper = getTestComponent(channelName, events, mockCallback); 420 | const mockChannel = echoInstance.private(channelName); 421 | 422 | events.forEach((event) => { 423 | expect(mockChannel.listen).toHaveBeenCalledWith( 424 | event, 425 | mockCallback, 426 | ); 427 | }); 428 | 429 | wrapper.vm.stopListening(); 430 | wrapper.vm.listen(); 431 | 432 | events.forEach((event) => { 433 | expect(mockChannel.listen).toHaveBeenCalledWith( 434 | event, 435 | mockCallback, 436 | ); 437 | }); 438 | 439 | wrapper.vm.stopListening(); 440 | 441 | events.forEach((event) => { 442 | expect(mockChannel.stopListening).toHaveBeenCalledWith( 443 | event, 444 | mockCallback, 445 | ); 446 | }); 447 | }); 448 | 449 | it("events and listeners are optional", async () => { 450 | const channelName = "test-channel"; 451 | 452 | wrapper = getTestComponent(channelName, undefined, undefined); 453 | 454 | expect(wrapper.vm.channel).not.toBeNull(); 455 | }); 456 | }); 457 | 458 | describe("useEchoPublic hook", async () => { 459 | let echoInstance: Echo<"null">; 460 | let wrapper: ReturnType; 461 | 462 | beforeEach(async () => { 463 | vi.resetModules(); 464 | 465 | echoInstance = new Echo({ 466 | broadcaster: "null", 467 | }); 468 | }); 469 | 470 | afterEach(() => { 471 | wrapper.unmount(); 472 | vi.clearAllMocks(); 473 | }); 474 | 475 | it("subscribes to a public channel and listens for events", async () => { 476 | const mockCallback = vi.fn(); 477 | const channelName = "test-channel"; 478 | const event = "test-event"; 479 | 480 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 481 | 482 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 483 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 484 | 485 | expect(wrapper.vm).toHaveProperty("leave"); 486 | expect(typeof wrapper.vm.leave).toBe("function"); 487 | }); 488 | 489 | it("handles multiple events", async () => { 490 | const mockCallback = vi.fn(); 491 | const channelName = "test-channel"; 492 | const events = ["event1", "event2"]; 493 | 494 | wrapper = getPublicTestComponent(channelName, events, mockCallback); 495 | 496 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 497 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 498 | 499 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 500 | 501 | const channel = echoInstance.channel(channelName); 502 | 503 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 504 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 505 | 506 | wrapper.unmount(); 507 | 508 | expect(channel.stopListening).toHaveBeenCalledWith( 509 | events[0], 510 | mockCallback, 511 | ); 512 | expect(channel.stopListening).toHaveBeenCalledWith( 513 | events[1], 514 | mockCallback, 515 | ); 516 | }); 517 | 518 | it("cleans up subscriptions on unmount", async () => { 519 | const mockCallback = vi.fn(); 520 | const channelName = "test-channel"; 521 | const event = "test-event"; 522 | 523 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 524 | 525 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 526 | 527 | wrapper.unmount(); 528 | 529 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 530 | }); 531 | 532 | it("won't subscribe multiple times to the same channel", async () => { 533 | const mockCallback = vi.fn(); 534 | const channelName = "test-channel"; 535 | const event = "test-event"; 536 | 537 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 538 | 539 | const wrapper2 = getPublicTestComponent( 540 | channelName, 541 | event, 542 | mockCallback, 543 | ); 544 | 545 | expect(echoInstance.channel).toHaveBeenCalledTimes(1); 546 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 547 | 548 | wrapper.unmount(); 549 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 550 | 551 | wrapper2.unmount(); 552 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 553 | }); 554 | 555 | it("can leave a channel", async () => { 556 | const mockCallback = vi.fn(); 557 | const channelName = "test-channel"; 558 | const event = "test-event"; 559 | 560 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 561 | 562 | wrapper.vm.leaveChannel(); 563 | 564 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 565 | }); 566 | 567 | it("can leave all channel variations", async () => { 568 | const mockCallback = vi.fn(); 569 | const channelName = "test-channel"; 570 | const event = "test-event"; 571 | 572 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 573 | 574 | wrapper.vm.leave(); 575 | 576 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 577 | }); 578 | 579 | it("events and listeners are optional", async () => { 580 | const channelName = "test-channel"; 581 | 582 | wrapper = getPublicTestComponent(channelName, undefined, undefined); 583 | 584 | expect(wrapper.vm.channel).not.toBeNull(); 585 | }); 586 | }); 587 | 588 | describe("useEchoPresence hook", async () => { 589 | let echoInstance: Echo<"null">; 590 | let wrapper: ReturnType; 591 | 592 | beforeEach(async () => { 593 | vi.resetModules(); 594 | 595 | echoInstance = new Echo({ 596 | broadcaster: "null", 597 | }); 598 | }); 599 | 600 | afterEach(() => { 601 | wrapper.unmount(); 602 | vi.clearAllMocks(); 603 | }); 604 | 605 | it("subscribes to a presence channel and listens for events", async () => { 606 | const mockCallback = vi.fn(); 607 | const channelName = "test-channel"; 608 | const event = "test-event"; 609 | 610 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 611 | 612 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 613 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 614 | 615 | expect(wrapper.vm).toHaveProperty("leave"); 616 | expect(typeof wrapper.vm.leave).toBe("function"); 617 | 618 | expect(wrapper.vm).toHaveProperty("channel"); 619 | expect(wrapper.vm.channel).not.toBeNull(); 620 | expect(typeof wrapper.vm.channel().here).toBe("function"); 621 | expect(typeof wrapper.vm.channel().joining).toBe("function"); 622 | expect(typeof wrapper.vm.channel().leaving).toBe("function"); 623 | expect(typeof wrapper.vm.channel().whisper).toBe("function"); 624 | }); 625 | 626 | it("handles multiple events", async () => { 627 | const mockCallback = vi.fn(); 628 | const channelName = "test-channel"; 629 | const events = ["event1", "event2"]; 630 | 631 | wrapper = getPresenceTestComponent(channelName, events, mockCallback); 632 | 633 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 634 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 635 | 636 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 637 | 638 | const channel = echoInstance.join(channelName); 639 | 640 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 641 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 642 | 643 | wrapper.unmount(); 644 | 645 | expect(channel.stopListening).toHaveBeenCalledWith( 646 | events[0], 647 | mockCallback, 648 | ); 649 | expect(channel.stopListening).toHaveBeenCalledWith( 650 | events[1], 651 | mockCallback, 652 | ); 653 | }); 654 | 655 | it("cleans up subscriptions on unmount", async () => { 656 | const mockCallback = vi.fn(); 657 | const channelName = "test-channel"; 658 | const event = "test-event"; 659 | 660 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 661 | 662 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 663 | 664 | wrapper.unmount(); 665 | 666 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 667 | `presence-${channelName}`, 668 | ); 669 | }); 670 | 671 | it("won't subscribe multiple times to the same channel", async () => { 672 | const mockCallback = vi.fn(); 673 | const channelName = "test-channel"; 674 | const event = "test-event"; 675 | 676 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 677 | 678 | const wrapper2 = getPresenceTestComponent( 679 | channelName, 680 | event, 681 | mockCallback, 682 | ); 683 | 684 | expect(echoInstance.join).toHaveBeenCalledTimes(1); 685 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 686 | 687 | wrapper.unmount(); 688 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 689 | 690 | wrapper2.unmount(); 691 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 692 | `presence-${channelName}`, 693 | ); 694 | }); 695 | 696 | it("can leave a channel", async () => { 697 | const mockCallback = vi.fn(); 698 | const channelName = "test-channel"; 699 | const event = "test-event"; 700 | 701 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 702 | 703 | wrapper.vm.leaveChannel(); 704 | 705 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 706 | `presence-${channelName}`, 707 | ); 708 | }); 709 | 710 | it("can leave all channel variations", async () => { 711 | const mockCallback = vi.fn(); 712 | const channelName = "test-channel"; 713 | const event = "test-event"; 714 | 715 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 716 | 717 | wrapper.vm.leave(); 718 | 719 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 720 | }); 721 | 722 | it("events and listeners are optional", async () => { 723 | const channelName = "test-channel"; 724 | 725 | wrapper = getPresenceTestComponent(channelName, undefined, undefined); 726 | 727 | expect(wrapper.vm.channel).not.toBeNull(); 728 | }); 729 | }); 730 | 731 | describe("useEchoNotification hook", async () => { 732 | let echoInstance: Echo<"null">; 733 | let wrapper: ReturnType; 734 | 735 | beforeEach(async () => { 736 | vi.resetModules(); 737 | 738 | echoInstance = new Echo({ 739 | broadcaster: "null", 740 | }); 741 | }); 742 | 743 | afterEach(() => { 744 | wrapper.unmount(); 745 | vi.clearAllMocks(); 746 | }); 747 | 748 | it("subscribes to a private channel and listens for notifications", async () => { 749 | const mockCallback = vi.fn(); 750 | const channelName = "test-channel"; 751 | 752 | wrapper = getNotificationTestComponent( 753 | channelName, 754 | mockCallback, 755 | undefined, 756 | ); 757 | 758 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 759 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 760 | 761 | expect(wrapper.vm).toHaveProperty("leave"); 762 | expect(typeof wrapper.vm.leave).toBe("function"); 763 | 764 | expect(wrapper.vm).toHaveProperty("listen"); 765 | expect(typeof wrapper.vm.listen).toBe("function"); 766 | 767 | expect(wrapper.vm).toHaveProperty("stopListening"); 768 | expect(typeof wrapper.vm.stopListening).toBe("function"); 769 | }); 770 | 771 | it("sets up a notification listener on a channel", async () => { 772 | const mockCallback = vi.fn(); 773 | const channelName = "test-channel"; 774 | 775 | wrapper = getNotificationTestComponent( 776 | channelName, 777 | mockCallback, 778 | undefined, 779 | ); 780 | 781 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 782 | 783 | const channel = echoInstance.private(channelName); 784 | expect(channel.notification).toHaveBeenCalled(); 785 | }); 786 | 787 | it("handles notification filtering by event type", async () => { 788 | const mockCallback = vi.fn(); 789 | const channelName = "test-channel"; 790 | const eventType = "specific-type"; 791 | 792 | wrapper = getNotificationTestComponent( 793 | channelName, 794 | mockCallback, 795 | eventType, 796 | ); 797 | 798 | const channel = echoInstance.private(channelName); 799 | expect(channel.notification).toHaveBeenCalled(); 800 | 801 | const notificationCallback = vi.mocked(channel.notification).mock 802 | .calls[0][0]; 803 | 804 | const matchingNotification = { 805 | type: eventType, 806 | data: { message: "test" }, 807 | }; 808 | const nonMatchingNotification = { 809 | type: "other-type", 810 | data: { message: "test" }, 811 | }; 812 | 813 | notificationCallback(matchingNotification); 814 | notificationCallback(nonMatchingNotification); 815 | 816 | expect(mockCallback).toHaveBeenCalledWith(matchingNotification); 817 | expect(mockCallback).toHaveBeenCalledTimes(1); 818 | expect(mockCallback).not.toHaveBeenCalledWith(nonMatchingNotification); 819 | }); 820 | 821 | it("handles multiple notification event types", async () => { 822 | const mockCallback = vi.fn(); 823 | const channelName = "test-channel"; 824 | const events = ["type1", "type2"]; 825 | 826 | wrapper = getNotificationTestComponent( 827 | channelName, 828 | mockCallback, 829 | events, 830 | ); 831 | 832 | const channel = echoInstance.private(channelName); 833 | expect(channel.notification).toHaveBeenCalled(); 834 | 835 | const notificationCallback = vi.mocked(channel.notification).mock 836 | .calls[0][0]; 837 | 838 | const notification1 = { type: events[0], data: {} }; 839 | const notification2 = { type: events[1], data: {} }; 840 | const notification3 = { type: "type3", data: {} }; 841 | 842 | notificationCallback(notification1); 843 | notificationCallback(notification2); 844 | notificationCallback(notification3); 845 | 846 | expect(mockCallback).toHaveBeenCalledWith(notification1); 847 | expect(mockCallback).toHaveBeenCalledWith(notification2); 848 | expect(mockCallback).toHaveBeenCalledTimes(2); 849 | expect(mockCallback).not.toHaveBeenCalledWith(notification3); 850 | }); 851 | 852 | it("handles dotted and slashed notification event types", async () => { 853 | const mockCallback = vi.fn(); 854 | const channelName = "test-channel"; 855 | const events = [ 856 | "App.Notifications.First", 857 | "App\\Notifications\\Second", 858 | ]; 859 | 860 | wrapper = getNotificationTestComponent( 861 | channelName, 862 | mockCallback, 863 | events, 864 | ); 865 | 866 | const channel = echoInstance.private(channelName); 867 | expect(channel.notification).toHaveBeenCalled(); 868 | 869 | const notificationCallback = vi.mocked(channel.notification).mock 870 | .calls[0][0]; 871 | 872 | const notification1 = { 873 | type: "App\\Notifications\\First", 874 | data: {}, 875 | }; 876 | const notification2 = { 877 | type: "App\\Notifications\\Second", 878 | data: {}, 879 | }; 880 | 881 | notificationCallback(notification1); 882 | notificationCallback(notification2); 883 | 884 | expect(mockCallback).toHaveBeenCalledWith(notification1); 885 | expect(mockCallback).toHaveBeenCalledWith(notification2); 886 | expect(mockCallback).toHaveBeenCalledTimes(2); 887 | }); 888 | 889 | it("accepts all notifications when no event types specified", async () => { 890 | const mockCallback = vi.fn(); 891 | const channelName = "test-channel"; 892 | 893 | wrapper = getNotificationTestComponent( 894 | channelName, 895 | mockCallback, 896 | undefined, 897 | ); 898 | 899 | const channel = echoInstance.private(channelName); 900 | expect(channel.notification).toHaveBeenCalled(); 901 | 902 | const notificationCallback = vi.mocked(channel.notification).mock 903 | .calls[0][0]; 904 | 905 | const notification1 = { type: "type1", data: {} }; 906 | const notification2 = { type: "type2", data: {} }; 907 | 908 | notificationCallback(notification1); 909 | notificationCallback(notification2); 910 | 911 | expect(mockCallback).toHaveBeenCalledWith(notification1); 912 | expect(mockCallback).toHaveBeenCalledWith(notification2); 913 | expect(mockCallback).toHaveBeenCalledTimes(2); 914 | }); 915 | 916 | it("cleans up subscriptions on unmount", async () => { 917 | const mockCallback = vi.fn(); 918 | const channelName = "test-channel"; 919 | 920 | wrapper = getNotificationTestComponent( 921 | channelName, 922 | mockCallback, 923 | undefined, 924 | ); 925 | 926 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 927 | 928 | wrapper.unmount(); 929 | 930 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 931 | `private-${channelName}`, 932 | ); 933 | }); 934 | 935 | it("won't subscribe multiple times to the same channel", async () => { 936 | const mockCallback = vi.fn(); 937 | const channelName = "test-channel"; 938 | 939 | wrapper = getNotificationTestComponent( 940 | channelName, 941 | mockCallback, 942 | undefined, 943 | ); 944 | 945 | const wrapper2 = getNotificationTestComponent( 946 | channelName, 947 | mockCallback, 948 | undefined, 949 | ); 950 | 951 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 952 | 953 | wrapper.unmount(); 954 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 955 | 956 | wrapper2.unmount(); 957 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 958 | `private-${channelName}`, 959 | ); 960 | }); 961 | 962 | it("can leave a channel", async () => { 963 | const mockCallback = vi.fn(); 964 | const channelName = "test-channel"; 965 | 966 | wrapper = getNotificationTestComponent( 967 | channelName, 968 | mockCallback, 969 | undefined, 970 | ); 971 | 972 | wrapper.vm.leaveChannel(); 973 | 974 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 975 | `private-${channelName}`, 976 | ); 977 | }); 978 | 979 | it("can leave all channel variations", async () => { 980 | const mockCallback = vi.fn(); 981 | const channelName = "test-channel"; 982 | 983 | wrapper = getNotificationTestComponent( 984 | channelName, 985 | mockCallback, 986 | undefined, 987 | ); 988 | 989 | wrapper.vm.leave(); 990 | 991 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 992 | }); 993 | 994 | it("can manually start and stop listening", async () => { 995 | const mockCallback = vi.fn(); 996 | const channelName = "test-channel"; 997 | 998 | wrapper = getNotificationTestComponent( 999 | channelName, 1000 | mockCallback, 1001 | undefined, 1002 | ); 1003 | 1004 | const channel = echoInstance.private(channelName); 1005 | expect(channel.notification).toHaveBeenCalledTimes(1); 1006 | 1007 | wrapper.vm.stopListening(); 1008 | wrapper.vm.listen(); 1009 | 1010 | expect(channel.notification).toHaveBeenCalledTimes(1); 1011 | }); 1012 | 1013 | it("stopListening prevents new notification listeners", async () => { 1014 | const mockCallback = vi.fn(); 1015 | const channelName = "test-channel"; 1016 | 1017 | wrapper = getNotificationTestComponent( 1018 | channelName, 1019 | mockCallback, 1020 | undefined, 1021 | ); 1022 | 1023 | wrapper.vm.stopListening(); 1024 | 1025 | expect(wrapper.vm.stopListening).toBeDefined(); 1026 | expect(typeof wrapper.vm.stopListening).toBe("function"); 1027 | }); 1028 | 1029 | it("callback and events are optional", async () => { 1030 | const channelName = "test-channel"; 1031 | 1032 | wrapper = getNotificationTestComponent( 1033 | channelName, 1034 | undefined, 1035 | undefined, 1036 | ); 1037 | 1038 | expect(wrapper.vm.channel).not.toBeNull(); 1039 | }); 1040 | }); 1041 | -------------------------------------------------------------------------------- /packages/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./dist", 5 | "emitDeclarationOnly": true, 6 | "module": "ES2020", 7 | "moduleResolution": "bundler", 8 | "outDir": "./dist", 9 | "sourceMap": false, 10 | "target": "ES2020", 11 | "verbatimModuleSyntax": true, 12 | "skipLibCheck": true, 13 | "importHelpers": true, 14 | "strictPropertyInitialization": false, 15 | "allowSyntheticDefaultImports": true, 16 | "isolatedModules": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "esModuleInterop": true, 20 | "strict": true, 21 | "typeRoots": ["node_modules/@types"], 22 | "lib": ["dom", "es2020"] 23 | }, 24 | "include": ["./typings/**/*.ts", "./src/**/*.ts"], 25 | "exclude": ["./node_modules", "./tests/**/*.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig, PluginOption, UserConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | const handleEnvVariablesPlugin = (): PluginOption => { 6 | return { 7 | name: "handle-env-variables-plugin", 8 | generateBundle(options, bundle) { 9 | for (const fileName in bundle) { 10 | const file = bundle[fileName]; 11 | 12 | if (file.type === "chunk" && file.fileName.endsWith(".js")) { 13 | const transformedContent = file.code.replace( 14 | /import\.meta\.env\.VITE_([A-Z0-9_]+)/g, 15 | "(typeof import.meta.env !== 'undefined' ? import.meta.env.VITE_$1 : undefined)", 16 | ); 17 | 18 | file.code = transformedContent; 19 | } 20 | } 21 | }, 22 | }; 23 | }; 24 | 25 | const config: UserConfig = (() => { 26 | const common: Partial = { 27 | rollupOptions: { 28 | external: ["vue", "pusher-js"], 29 | output: { 30 | globals: { 31 | vue: "Vue", 32 | "pusher-js": "Pusher", 33 | }, 34 | }, 35 | }, 36 | outDir: resolve(__dirname, "dist"), 37 | sourcemap: true, 38 | minify: true, 39 | }; 40 | 41 | if (process.env.FORMAT === "iife") { 42 | return { 43 | build: { 44 | lib: { 45 | entry: resolve(__dirname, "src/index.iife.ts"), 46 | name: "EchoVue", 47 | formats: ["iife"], 48 | fileName: () => "echo-vue.iife.js", 49 | }, 50 | emptyOutDir: false, // Don't empty the output directory for the second build 51 | ...common, 52 | }, 53 | }; 54 | } 55 | 56 | return { 57 | plugins: [ 58 | dts({ 59 | insertTypesEntry: true, 60 | rollupTypes: true, 61 | include: ["src/**/*.ts"], 62 | }), 63 | handleEnvVariablesPlugin(), 64 | ], 65 | define: { 66 | "import.meta.env.VITE_REVERB_APP_KEY": 67 | "import.meta.env.VITE_REVERB_APP_KEY", 68 | "import.meta.env.VITE_REVERB_HOST": 69 | "import.meta.env.VITE_REVERB_HOST", 70 | "import.meta.env.VITE_REVERB_PORT": 71 | "import.meta.env.VITE_REVERB_PORT", 72 | "import.meta.env.VITE_REVERB_SCHEME": 73 | "import.meta.env.VITE_REVERB_SCHEME", 74 | "import.meta.env.VITE_PUSHER_APP_KEY": 75 | "import.meta.env.VITE_PUSHER_APP_KEY", 76 | "import.meta.env.VITE_PUSHER_APP_CLUSTER": 77 | "import.meta.env.VITE_PUSHER_APP_CLUSTER", 78 | "import.meta.env.VITE_PUSHER_HOST": 79 | "import.meta.env.VITE_PUSHER_HOST", 80 | "import.meta.env.VITE_PUSHER_PORT": 81 | "import.meta.env.VITE_PUSHER_PORT", 82 | "import.meta.env.VITE_SOCKET_IO_HOST": 83 | "import.meta.env.VITE_SOCKET_IO_HOST", 84 | "import.meta.env.VITE_ABLY_PUBLIC_KEY": 85 | "import.meta.env.VITE_ABLY_PUBLIC_KEY", 86 | }, 87 | build: { 88 | lib: { 89 | entry: resolve(__dirname, "src/index.ts"), 90 | formats: ["es", "cjs"], 91 | fileName: (format, entryName) => { 92 | return `${entryName}.${format === "es" ? "js" : "common.js"}`; 93 | }, 94 | }, 95 | emptyOutDir: true, 96 | ...common, 97 | }, 98 | test: { 99 | globals: true, 100 | environment: "jsdom", 101 | }, 102 | }; 103 | })(); 104 | 105 | export default defineConfig(config); 106 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | get_current_version() { 4 | local package_json=$1 5 | if [ -f "$package_json" ]; then 6 | grep '"version":' "$package_json" | cut -d\" -f4 7 | else 8 | echo "Error: package.json not found at $package_json" 9 | exit 1 10 | fi 11 | } 12 | 13 | get_package_name() { 14 | local package_json=$1 15 | if [ -f "$package_json" ]; then 16 | grep '"name":' "$package_json" | cut -d\" -f4 17 | else 18 | echo "Error: package.json not found at $package_json" 19 | exit 1 20 | fi 21 | } 22 | 23 | if [ -n "$(git status --porcelain)" ]; then 24 | echo "Error: There are uncommitted changes in the working directory" 25 | echo "Please commit or stash these changes before proceeding" 26 | exit 1 27 | fi 28 | 29 | update_version() { 30 | local package_dir=$1 31 | local version_type=$2 32 | 33 | case $version_type in 34 | "patch") 35 | pnpm version patch --no-git-tag-version 36 | ;; 37 | "minor") 38 | pnpm version minor --no-git-tag-version 39 | ;; 40 | "major") 41 | pnpm version major --no-git-tag-version 42 | ;; 43 | *) 44 | echo "Invalid version type. Please choose patch/minor/major" 45 | exit 1 46 | ;; 47 | esac 48 | } 49 | 50 | echo "Starting package version management..." 51 | 52 | root_package_json="packages/laravel-echo/package.json" 53 | current_version=$(get_current_version "$root_package_json") 54 | echo "" 55 | echo "Current version: $current_version" 56 | echo "" 57 | 58 | read -p "Update version? (patch/minor/major): " version_type 59 | echo "" 60 | 61 | for package_dir in packages/*; do 62 | if [ -d "$package_dir" ]; then 63 | echo "Updating version for $package_dir" 64 | 65 | cd $package_dir 66 | 67 | update_version "$package_dir" "$version_type" 68 | 69 | cd ../.. 70 | 71 | echo "" 72 | fi 73 | done 74 | 75 | new_version=$(get_current_version "$root_package_json") 76 | 77 | echo "Updating lock file..." 78 | pnpm i 79 | 80 | echo "Staging package.json files..." 81 | git add "**/package.json" 82 | echo "" 83 | 84 | echo "Committing version changes..." 85 | git commit -m "v$new_version" 86 | echo "" 87 | 88 | echo "" 89 | echo "Creating git tag: v$new_version" 90 | git tag "v$new_version" 91 | git push --tags 92 | echo "" 93 | 94 | echo "Running release process..." 95 | echo "" 96 | 97 | for package_dir in packages/*; do 98 | if [ -d "$package_dir" ]; then 99 | echo "Releasing $package_dir" 100 | cd $package_dir 101 | pnpm run release 102 | cd ../.. 103 | echo "" 104 | fi 105 | done 106 | 107 | # Echo joke 108 | echo "Released! (Released!) (Released!)" 109 | 110 | echo "" 111 | 112 | echo "Release on GitHub:" 113 | echo "https://github.com/laravel/echo/releases/tag/v$new_version" 114 | --------------------------------------------------------------------------------