├── .nvmrc ├── pnpm-workspace.yaml ├── .prettierrc ├── packages ├── laravel-echo │ ├── src │ │ ├── index.iife.ts │ │ ├── connector │ │ │ ├── index.ts │ │ │ ├── null-connector.ts │ │ │ ├── connector.ts │ │ │ ├── socketio-connector.ts │ │ │ └── pusher-connector.ts │ │ ├── channel │ │ │ ├── null-private-channel.ts │ │ │ ├── null-encrypted-private-channel.ts │ │ │ ├── index.ts │ │ │ ├── socketio-private-channel.ts │ │ │ ├── pusher-private-channel.ts │ │ │ ├── pusher-encrypted-private-channel.ts │ │ │ ├── presence-channel.ts │ │ │ ├── null-presence-channel.ts │ │ │ ├── null-channel.ts │ │ │ ├── socketio-presence-channel.ts │ │ │ ├── pusher-presence-channel.ts │ │ │ ├── channel.ts │ │ │ ├── pusher-channel.ts │ │ │ └── socketio-channel.ts │ │ ├── util │ │ │ ├── index.ts │ │ │ └── event-formatter.ts │ │ └── echo.ts │ ├── typings │ │ ├── index.d.ts │ │ └── window.d.ts │ ├── tsconfig.json │ ├── eslint.config.mjs │ ├── tests │ │ ├── util │ │ │ └── event-formatter.test.ts │ │ ├── echo.test.ts │ │ └── channel │ │ │ └── socketio-channel.test.ts │ ├── vite.config.ts │ └── package.json ├── react │ ├── src │ │ ├── util │ │ │ └── index.ts │ │ ├── index.iife.ts │ │ ├── index.ts │ │ ├── vite-env.d.ts │ │ ├── types.ts │ │ ├── config │ │ │ └── index.ts │ │ └── hooks │ │ │ └── use-echo.ts │ ├── tsconfig.json │ ├── tests │ │ ├── config.test.ts │ │ └── use-echo.test.ts │ ├── eslint.config.mjs │ ├── README.md │ ├── package.json │ └── vite.config.ts └── vue │ ├── src │ ├── util │ │ └── index.ts │ ├── index.iife.ts │ ├── index.ts │ ├── vite-env.d.ts │ ├── types.ts │ ├── config │ │ └── index.ts │ └── composables │ │ └── useEcho.ts │ ├── tsconfig.json │ ├── tests │ ├── config.test.ts │ └── useEcho.test.ts │ ├── eslint.config.mjs │ ├── package.json │ ├── README.md │ └── vite.config.ts ├── .vscode └── tasks.json ├── package.json ├── LICENSE.md ├── CONTRIBUTING.md ├── README.md └── release.sh /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /packages/laravel-echo/src/index.iife.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./echo"; 2 | -------------------------------------------------------------------------------- /packages/react/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export const toArray = (item: T | T[]): T[] => 2 | Array.isArray(item) ? item : [item]; 3 | -------------------------------------------------------------------------------- /packages/vue/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export const toArray = (item: T | T[]): T[] => 2 | Array.isArray(item) ? item : [item]; 3 | -------------------------------------------------------------------------------- /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/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/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/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/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export { configureEcho, echo, echoIsConfigured } from "./config/index"; 2 | export { 3 | useEcho, 4 | useEchoModel, 5 | useEchoNotification, 6 | useEchoPresence, 7 | useEchoPublic, 8 | } from "./hooks/use-echo"; 9 | -------------------------------------------------------------------------------- /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, echoIsConfigured } from "./config/index"; 9 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | "dev": "pnpm -r --parallel --filter './packages/*' dev" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/react/tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | describe("echo helper", () => { 4 | beforeEach(() => { 5 | vi.resetModules(); 6 | }); 7 | 8 | it("throws error when Echo is not configured", async () => { 9 | const { echo } = await import("../src/config"); 10 | 11 | expect(() => echo()).toThrow("Echo has not been configured"); 12 | }); 13 | 14 | it("creates Echo instance with proper configuration", async () => { 15 | const { configureEcho, echo } = await import("../src/config"); 16 | 17 | configureEcho({ 18 | broadcaster: "null", 19 | }); 20 | 21 | expect(echo()).toBeDefined(); 22 | expect(echo().options.broadcaster).toBe("null"); 23 | }); 24 | 25 | it("checks if Echo is configured", async () => { 26 | const { configureEcho, echoIsConfigured } = await import( 27 | "../src/config" 28 | ); 29 | 30 | expect(echoIsConfigured()).toBe(false); 31 | 32 | configureEcho({ 33 | broadcaster: "null", 34 | }); 35 | 36 | expect(echoIsConfigured()).toBe(true); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/vue/tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | describe("echo helper", async () => { 4 | beforeEach(() => { 5 | vi.resetModules(); 6 | }); 7 | 8 | afterEach(() => { 9 | vi.clearAllMocks(); 10 | }); 11 | 12 | it("throws error when Echo is not configured", async () => { 13 | const { echo } = await import("../src/config"); 14 | 15 | expect(() => echo()).toThrow("Echo has not been configured"); 16 | }); 17 | 18 | it("creates Echo instance with proper configuration", async () => { 19 | const { configureEcho, echo } = await import("../src/config"); 20 | 21 | configureEcho({ 22 | broadcaster: "null", 23 | }); 24 | 25 | expect(echo()).toBeDefined(); 26 | }); 27 | 28 | it("checks if Echo is configured", async () => { 29 | const { configureEcho, echoIsConfigured: echoIsConfigured } = 30 | await import("../src/config"); 31 | 32 | expect(echoIsConfigured()).toBe(false); 33 | 34 | configureEcho({ 35 | broadcaster: "null", 36 | }); 37 | 38 | expect(echoIsConfigured()).toBe(true); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to Laravel Echo! Your contributions help make this project better for everyone. 4 | 5 | Echo is maintained as a monorepo using [pnpm workspaces](https://pnpm.io/workspaces). Below you'll find an overview of the repository and how to get your development environment running. 6 | 7 | > **Note:** You'll need **pnpm version 10 or higher**. If you're unsure which version you have, run `pnpm -v`. 8 | 9 | ## Repository Overview 10 | 11 | ``` 12 | echo/ 13 | ├── packages/ 14 | │ ├── laravel-echo/ Core library 15 | │ ├── react/ React hooks 16 | │ │ └── tests/ React tests 17 | │ └── vue/ Vue hooks 18 | │ └── tests/ Vue Tests 19 | ``` 20 | 21 | ## Getting Started 22 | 23 | Clone the repository and install the dependencies: 24 | 25 | ```sh 26 | git clone https://github.com/laravel/echo.git echo 27 | cd echo 28 | pnpm install 29 | ``` 30 | 31 | Then, start the development environment: 32 | 33 | ```sh 34 | pnpm dev 35 | ``` 36 | 37 | This builds the core library and of the package variants, and starts a file watcher that will automatically rebuild each package when changes are made. 38 | 39 | If you prefer, you can also start individual watchers from each package directory. For example: 40 | 41 | ```sh 42 | cd packages/laravel-echo && pnpm dev 43 | cd packages/react && pnpm dev 44 | cd packages/vue && pnpm dev 45 | ``` 46 | 47 | > **Note:** The core package (`packages/laravel-echo`) must always be running, as all adapters depend on it. 48 | 49 | ## Running Tests 50 | 51 | Run all tests: 52 | 53 | ```sh 54 | pnpm test 55 | ``` 56 | 57 | Run the test suite for a specific adapter: 58 | 59 | ```sh 60 | cd packages/laravel-echo && pnpm test 61 | cd packages/react && pnpm test 62 | cd packages/vue && pnpm test 63 | ``` 64 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | * The name for Broadcast Notification Created events. 15 | */ 16 | notificationCreatedEvent: string = 17 | ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated"; 18 | 19 | /** 20 | * Listen for an event on the channel instance. 21 | */ 22 | abstract listen(event: string, callback: CallableFunction): this; 23 | 24 | /** 25 | * Listen for a whisper event on the channel instance. 26 | */ 27 | listenForWhisper(event: string, callback: CallableFunction): this { 28 | return this.listen(".client-" + event, callback); 29 | } 30 | 31 | /** 32 | * Listen for an event on the channel instance. 33 | */ 34 | notification(callback: CallableFunction): this { 35 | return this.listen(this.notificationCreatedEvent, callback); 36 | } 37 | 38 | /** 39 | * Stop listening to an event on the channel instance. 40 | */ 41 | abstract stopListening(event: string, callback?: CallableFunction): this; 42 | 43 | /** 44 | * Stop listening for notification events on the channel instance. 45 | */ 46 | stopListeningForNotification(callback: CallableFunction): this { 47 | return this.stopListening(this.notificationCreatedEvent, callback); 48 | } 49 | 50 | /** 51 | * Stop listening for a whisper event on the channel instance. 52 | */ 53 | stopListeningForWhisper(event: string, callback?: CallableFunction): this { 54 | return this.stopListening(".client-" + event, callback); 55 | } 56 | 57 | /** 58 | * Register a callback to be called anytime a subscription succeeds. 59 | */ 60 | abstract subscribed(callback: CallableFunction): this; 61 | 62 | /** 63 | * Register a callback to be called anytime an error occurs. 64 | */ 65 | abstract error(callback: CallableFunction): this; 66 | } 67 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-echo", 3 | "version": "2.2.6", 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 | "dev": "vite dev", 26 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 27 | "prepublish": "pnpm run build", 28 | "release": "vitest --run && pnpm publish --no-git-checks", 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 | "@types/jquery": "^3.5.32", 42 | "@types/node": "^20.0.0", 43 | "@typescript-eslint/eslint-plugin": "^8.21.0", 44 | "@typescript-eslint/parser": "^8.21.0", 45 | "axios": "^1.12.0", 46 | "eslint": "^9.0.0", 47 | "prettier": "^3.5.3", 48 | "pusher-js": "^8.0", 49 | "socket.io-client": "^4.0", 50 | "tslib": "^2.8.1", 51 | "typescript": "^5.7.0", 52 | "vite": "^5.4.21", 53 | "vite-plugin-dts": "^3.0.0", 54 | "vitest": "^3.1.2" 55 | }, 56 | "peerDependencies": { 57 | "pusher-js": "*", 58 | "socket.io-client": "*" 59 | }, 60 | "typesVersions": { 61 | "*": { 62 | "socket.io-client": [], 63 | "pusher-js": [] 64 | } 65 | }, 66 | "engines": { 67 | "node": ">=20" 68 | }, 69 | "exports": { 70 | ".": { 71 | "types": "./dist/echo.d.ts", 72 | "import": "./dist/echo.js", 73 | "require": "./dist/echo.common.js" 74 | }, 75 | "./iife": "./dist/echo.iife.js" 76 | }, 77 | "overrides": { 78 | "glob": "^9.0.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laravel/echo-vue", 3 | "version": "2.2.6", 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 | "dev": "vite dev", 27 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 28 | "prepublish": "pnpm run build", 29 | "release": "vitest --run && pnpm publish --no-git-checks", 30 | "test": "vitest", 31 | "format": "prettier --write ." 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.26.7", 35 | "@babel/plugin-proposal-decorators": "^7.25.9", 36 | "@babel/plugin-proposal-function-sent": "^7.25.9", 37 | "@babel/plugin-proposal-throw-expressions": "^7.25.9", 38 | "@babel/plugin-transform-export-namespace-from": "^7.25.9", 39 | "@babel/plugin-transform-numeric-separator": "^7.25.9", 40 | "@babel/plugin-transform-object-assign": "^7.25.9", 41 | "@babel/preset-env": "^7.26.7", 42 | "@testing-library/vue": "^8.1.0", 43 | "@types/node": "^22.15.3", 44 | "@typescript-eslint/eslint-plugin": "^8.21.0", 45 | "@typescript-eslint/parser": "^8.21.0", 46 | "@vue/test-utils": "^2.4.6", 47 | "eslint": "^9.0.0", 48 | "laravel-echo": "workspace:^", 49 | "prettier": "^3.5.3", 50 | "pusher-js": "^8.0", 51 | "socket.io-client": "^4.0", 52 | "tslib": "^2.8.1", 53 | "typescript": "^5.7.0", 54 | "vite": "^6.3.3", 55 | "vite-plugin-dts": "^4.5.3", 56 | "vitest": "^3.1.2" 57 | }, 58 | "peerDependencies": { 59 | "pusher-js": "*", 60 | "socket.io-client": "*", 61 | "vue": "^3.0.0" 62 | }, 63 | "typesVersions": { 64 | "*": { 65 | "socket.io-client": [], 66 | "pusher-js": [] 67 | } 68 | }, 69 | "engines": { 70 | "node": ">=20" 71 | }, 72 | "exports": { 73 | ".": { 74 | "types": "./dist/index.d.ts", 75 | "import": "./dist/index.js", 76 | "require": "./dist/index.common.js" 77 | } 78 | }, 79 | "overrides": { 80 | "glob": "^9.0.0" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /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 | 86 | export const echoIsConfigured = () => echoConfig !== null; 87 | -------------------------------------------------------------------------------- /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/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/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 | export const echoIsConfigured = () => echoConfig !== null; 27 | 28 | /** 29 | * Configure the Echo instance with sensible defaults. 30 | * 31 | * @link https://laravel.com/docs/broadcasting#client-side-installation 32 | */ 33 | export const configureEcho = ( 34 | config: EchoOptions, 35 | ): void => { 36 | const defaults: ConfigDefaults = { 37 | reverb: { 38 | broadcaster: "reverb", 39 | key: import.meta.env.VITE_REVERB_APP_KEY, 40 | wsHost: import.meta.env.VITE_REVERB_HOST, 41 | wsPort: import.meta.env.VITE_REVERB_PORT, 42 | wssPort: import.meta.env.VITE_REVERB_PORT, 43 | forceTLS: 44 | (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https", 45 | enabledTransports: ["ws", "wss"], 46 | }, 47 | pusher: { 48 | broadcaster: "pusher", 49 | key: import.meta.env.VITE_PUSHER_APP_KEY, 50 | cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, 51 | forceTLS: true, 52 | wsHost: import.meta.env.VITE_PUSHER_HOST, 53 | wsPort: import.meta.env.VITE_PUSHER_PORT, 54 | wssPort: import.meta.env.VITE_PUSHER_PORT, 55 | enabledTransports: ["ws", "wss"], 56 | }, 57 | "socket.io": { 58 | broadcaster: "socket.io", 59 | host: import.meta.env.VITE_SOCKET_IO_HOST, 60 | }, 61 | null: { 62 | broadcaster: "null", 63 | }, 64 | ably: { 65 | broadcaster: "pusher", 66 | key: import.meta.env.VITE_ABLY_PUBLIC_KEY, 67 | wsHost: "realtime-pusher.ably.io", 68 | wsPort: 443, 69 | disableStats: true, 70 | encrypted: true, 71 | }, 72 | }; 73 | 74 | echoConfig = { 75 | ...defaults[config.broadcaster], 76 | ...config, 77 | } as EchoOptions; 78 | 79 | // Reset the instance if it was already created 80 | if (echoInstance) { 81 | echoInstance = null; 82 | } 83 | }; 84 | 85 | export const echo = (): Echo => 86 | getEchoInstance(); 87 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laravel/echo-react", 3 | "version": "2.2.6", 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 | "dev": "vite dev", 27 | "lint": "eslint --config eslint.config.mjs \"src/**/*.ts\"", 28 | "prepublish": "pnpm run build", 29 | "release": "vitest --run && pnpm publish --no-git-checks", 30 | "test": "vitest", 31 | "format": "prettier --write ." 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.26.7", 35 | "@babel/plugin-proposal-decorators": "^7.25.9", 36 | "@babel/plugin-proposal-function-sent": "^7.25.9", 37 | "@babel/plugin-proposal-throw-expressions": "^7.25.9", 38 | "@babel/plugin-transform-export-namespace-from": "^7.25.9", 39 | "@babel/plugin-transform-numeric-separator": "^7.25.9", 40 | "@babel/plugin-transform-object-assign": "^7.25.9", 41 | "@babel/preset-env": "^7.26.7", 42 | "@testing-library/dom": "^10.4.0", 43 | "@testing-library/react": "^14.3.1", 44 | "@testing-library/react-hooks": "^8.0.1", 45 | "@types/node": "^20.0.0", 46 | "@types/react": "^19.1.2", 47 | "@types/react-dom": "^19.1.2", 48 | "@typescript-eslint/eslint-plugin": "^8.21.0", 49 | "@typescript-eslint/parser": "^8.21.0", 50 | "eslint": "^9.0.0", 51 | "jsdom": "^26.1.0", 52 | "laravel-echo": "workspace:^", 53 | "prettier": "^3.5.3", 54 | "pusher-js": "^8.0", 55 | "react": "^19.1.0", 56 | "react-dom": "^19.1.0", 57 | "socket.io-client": "^4.0", 58 | "tslib": "^2.8.1", 59 | "typescript": "^5.7.0", 60 | "vite": "^5.4.21", 61 | "vite-plugin-dts": "^3.7.0", 62 | "vitest": "^3.1.2" 63 | }, 64 | "peerDependencies": { 65 | "pusher-js": "*", 66 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 67 | "socket.io-client": "*" 68 | }, 69 | "typesVersions": { 70 | "*": { 71 | "socket.io-client": [], 72 | "pusher-js": [] 73 | } 74 | }, 75 | "engines": { 76 | "node": ">=20" 77 | }, 78 | "exports": { 79 | ".": { 80 | "types": "./dist/index.d.ts", 81 | "import": "./dist/index.js", 82 | "require": "./dist/index.common.js" 83 | } 84 | }, 85 | "overrides": { 86 | "glob": "^9.0.0" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | REPO="laravel/echo" 5 | BRANCH="2.x" 6 | 7 | # Ensure we are on correct branch and the working tree is clean 8 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) 9 | if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then 10 | echo "Error: must be on $BRANCH branch (current: $CURRENT_BRANCH)" >&2 11 | exit 1 12 | fi 13 | 14 | if [ -n "$(git status --porcelain)" ]; then 15 | echo "Error: working tree is not clean. Commit or stash changes before releasing." >&2 16 | git status --porcelain 17 | exit 1 18 | fi 19 | 20 | get_current_version() { 21 | local package_json=$1 22 | if [ -f "$package_json" ]; then 23 | grep '"version":' "$package_json" | cut -d\" -f4 24 | else 25 | echo "Error: package.json not found at $package_json" 26 | exit 1 27 | fi 28 | } 29 | 30 | get_package_name() { 31 | local package_json=$1 32 | if [ -f "$package_json" ]; then 33 | grep '"name":' "$package_json" | cut -d\" -f4 34 | else 35 | echo "Error: package.json not found at $package_json" 36 | exit 1 37 | fi 38 | } 39 | 40 | update_version() { 41 | local package_dir=$1 42 | local version_type=$2 43 | 44 | case $version_type in 45 | "patch") 46 | pnpm version patch --no-git-tag-version 47 | ;; 48 | "minor") 49 | pnpm version minor --no-git-tag-version 50 | ;; 51 | "major") 52 | pnpm version major --no-git-tag-version 53 | ;; 54 | *) 55 | echo "Invalid version type. Please choose patch/minor/major" 56 | exit 1 57 | ;; 58 | esac 59 | } 60 | 61 | if [ -n "$(git status --porcelain)" ]; then 62 | echo "Error: There are uncommitted changes in the working directory" 63 | echo "Please commit or stash these changes before proceeding" 64 | exit 1 65 | fi 66 | 67 | git pull 68 | 69 | ROOT_PACKAGE_JSON="packages/react/package.json" 70 | CURRENT_VERSION=$(get_current_version "$ROOT_PACKAGE_JSON") 71 | echo "" 72 | echo "Current version: $CURRENT_VERSION" 73 | echo "" 74 | 75 | echo "Select version bump type:" 76 | echo "1) patch (bug fixes)" 77 | echo "2) minor (new features)" 78 | echo "3) major (breaking changes)" 79 | echo 80 | 81 | read -p "Enter your choice (1-3): " choice 82 | 83 | case $choice in 84 | 1) 85 | RELEASE_TYPE="patch" 86 | ;; 87 | 2) 88 | RELEASE_TYPE="minor" 89 | ;; 90 | 3) 91 | RELEASE_TYPE="major" 92 | ;; 93 | *) 94 | echo "❌ Invalid choice. Exiting." 95 | exit 1 96 | ;; 97 | esac 98 | 99 | for package_dir in packages/*; do 100 | if [ -d "$package_dir" ]; then 101 | echo "Updating version for $package_dir" 102 | 103 | cd $package_dir 104 | 105 | update_version "$package_dir" "$RELEASE_TYPE" 106 | 107 | cd ../.. 108 | 109 | echo "" 110 | fi 111 | done 112 | 113 | NEW_VERSION=$(get_current_version "$ROOT_PACKAGE_JSON") 114 | TAG="v$NEW_VERSION" 115 | 116 | echo "Updating lock file..." 117 | pnpm i 118 | echo "" 119 | 120 | echo "Staging package.json files..." 121 | git add "**/package.json" 122 | echo "" 123 | 124 | git commit -m "$TAG" 125 | git tag -a "$TAG" -m "$TAG" 126 | git push 127 | git push --tags 128 | 129 | gh release create "$TAG" --generate-notes 130 | 131 | echo "" 132 | echo "✅ Release $TAG completed successfully, publishing kicked off in CI." 133 | echo "🔗 https://github.com/$REPO/releases/tag/$TAG" 134 | 135 | # Echo joke 136 | echo "Released! (Released!) (Released!)" 137 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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.io.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/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/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/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 | result.channel().stopListeningForNotification(cb); 241 | listening.value = false; 242 | }; 243 | 244 | onMounted(() => { 245 | listen(); 246 | }); 247 | 248 | onUnmounted(() => { 249 | stopListening(); 250 | }); 251 | 252 | return { 253 | ...result, 254 | /** 255 | * Stop listening for notification events 256 | */ 257 | stopListening, 258 | /** 259 | * Listen for notification events 260 | */ 261 | listen, 262 | }; 263 | }; 264 | 265 | export const useEchoPresence = < 266 | TPayload, 267 | TDriver extends BroadcastDriver = BroadcastDriver, 268 | >( 269 | channelName: string, 270 | event: string | string[] = [], 271 | callback: (payload: TPayload) => void = () => {}, 272 | dependencies: any[] = [], 273 | ) => { 274 | return useEcho( 275 | channelName, 276 | event, 277 | callback, 278 | dependencies, 279 | "presence", 280 | ); 281 | }; 282 | 283 | export const useEchoPublic = < 284 | TPayload, 285 | TDriver extends BroadcastDriver = BroadcastDriver, 286 | >( 287 | channelName: string, 288 | event: string | string[] = [], 289 | callback: (payload: TPayload) => void = () => {}, 290 | dependencies: any[] = [], 291 | ) => { 292 | return useEcho( 293 | channelName, 294 | event, 295 | callback, 296 | dependencies, 297 | "public", 298 | ); 299 | }; 300 | 301 | export const useEchoModel = < 302 | TPayload, 303 | TModel extends string, 304 | TDriver extends BroadcastDriver = BroadcastDriver, 305 | >( 306 | model: TModel, 307 | identifier: string | number, 308 | event: ModelEvents | ModelEvents[] = [], 309 | callback: (payload: ModelPayload) => void = () => {}, 310 | dependencies: any[] = [], 311 | ) => { 312 | return useEcho, TDriver, "private">( 313 | `${model}.${identifier}`, 314 | toArray(event).map((e) => (e.startsWith(".") ? e : `.${e}`)), 315 | callback, 316 | dependencies, 317 | "private", 318 | ); 319 | }; 320 | -------------------------------------------------------------------------------- /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 | const leave = useCallback(() => { 131 | tearDown(true); 132 | }, dependencies); 133 | 134 | useEffect(() => { 135 | if (initialized.current) { 136 | subscription.current = resolveChannelSubscription(channel); 137 | } 138 | 139 | initialized.current = true; 140 | 141 | listen(); 142 | 143 | return tearDown; 144 | }, dependencies); 145 | 146 | return { 147 | /** 148 | * Leave the channel 149 | */ 150 | leaveChannel: tearDown, 151 | /** 152 | * Leave the channel and also its associated private and presence channels 153 | */ 154 | leave, 155 | /** 156 | * Stop listening for event(s) without leaving the channel 157 | */ 158 | stopListening, 159 | /** 160 | * Listen for event(s) 161 | */ 162 | listen, 163 | /** 164 | * Channel instance 165 | */ 166 | channel: () => 167 | subscription.current as ChannelReturnType, 168 | }; 169 | }; 170 | 171 | export const useEchoNotification = < 172 | TPayload, 173 | TDriver extends BroadcastDriver = BroadcastDriver, 174 | >( 175 | channelName: string, 176 | callback: (payload: BroadcastNotification) => void = () => {}, 177 | event: string | string[] = [], 178 | dependencies: any[] = [], 179 | ) => { 180 | const result = useEcho, TDriver, "private">( 181 | channelName, 182 | [], 183 | callback, 184 | dependencies, 185 | "private", 186 | ); 187 | 188 | const events = useRef( 189 | toArray(event) 190 | .map((e) => { 191 | if (e.includes(".")) { 192 | return [e, e.replace(/\./g, "\\")]; 193 | } 194 | 195 | return [e, e.replace(/\\/g, ".")]; 196 | }) 197 | .flat(), 198 | ); 199 | const listening = useRef(false); 200 | const initialized = useRef(false); 201 | 202 | const cb = useCallback( 203 | (notification: BroadcastNotification) => { 204 | if (!listening.current) { 205 | return; 206 | } 207 | 208 | if ( 209 | events.current.length === 0 || 210 | events.current.includes(notification.type) 211 | ) { 212 | callback(notification); 213 | } 214 | }, 215 | dependencies.concat(events.current).concat([callback]), 216 | ); 217 | 218 | const listen = useCallback(() => { 219 | if (listening.current) { 220 | return; 221 | } 222 | 223 | if (!initialized.current) { 224 | result.channel().notification(cb); 225 | } 226 | 227 | listening.current = true; 228 | initialized.current = true; 229 | }, [cb]); 230 | 231 | const stopListening = useCallback(() => { 232 | if (!listening.current) { 233 | return; 234 | } 235 | 236 | result.channel().stopListeningForNotification(cb); 237 | 238 | listening.current = false; 239 | }, [cb]); 240 | 241 | useEffect(() => { 242 | listen(); 243 | 244 | return () => stopListening(); 245 | }, dependencies.concat(events.current)); 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/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 & { broadcaster: T }) { 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 interceptors. 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/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 | stopListeningForNotification: vi.fn(), 138 | }; 139 | 140 | const mockPublicChannel = { 141 | leaveChannel: vi.fn(), 142 | listen: vi.fn(), 143 | stopListening: vi.fn(), 144 | }; 145 | 146 | const mockPresenceChannel = { 147 | leaveChannel: vi.fn(), 148 | listen: vi.fn(), 149 | stopListening: vi.fn(), 150 | here: vi.fn(), 151 | joining: vi.fn(), 152 | leaving: vi.fn(), 153 | whisper: vi.fn(), 154 | }; 155 | 156 | const Echo = vi.fn(); 157 | 158 | Echo.prototype.private = vi.fn(() => mockPrivateChannel); 159 | Echo.prototype.channel = vi.fn(() => mockPublicChannel); 160 | Echo.prototype.encryptedPrivate = vi.fn(); 161 | Echo.prototype.listen = vi.fn(); 162 | Echo.prototype.leave = vi.fn(); 163 | Echo.prototype.leaveChannel = vi.fn(); 164 | Echo.prototype.leaveAllChannels = vi.fn(); 165 | Echo.prototype.join = vi.fn(() => mockPresenceChannel); 166 | 167 | return { default: Echo }; 168 | }); 169 | 170 | describe("without echo configured", async () => { 171 | beforeEach(() => { 172 | vi.resetModules(); 173 | }); 174 | 175 | afterEach(() => { 176 | vi.clearAllMocks(); 177 | }); 178 | 179 | it("throws error when Echo is not configured", async () => { 180 | const mockCallback = vi.fn(); 181 | const channelName = "test-channel"; 182 | const event = "test-event"; 183 | 184 | expect(() => 185 | getUnConfiguredTestComponent( 186 | channelName, 187 | event, 188 | mockCallback, 189 | "private", 190 | ), 191 | ).toThrow("Echo has not been configured"); 192 | }); 193 | }); 194 | 195 | describe("useEcho hook", async () => { 196 | let echoInstance: Echo<"null">; 197 | let wrapper: ReturnType; 198 | 199 | beforeEach(async () => { 200 | vi.resetModules(); 201 | 202 | echoInstance = new Echo({ 203 | broadcaster: "null", 204 | }); 205 | }); 206 | 207 | afterEach(() => { 208 | wrapper.unmount(); 209 | vi.clearAllMocks(); 210 | }); 211 | 212 | it("subscribes to a channel and listens for events", async () => { 213 | const mockCallback = vi.fn(); 214 | const channelName = "test-channel"; 215 | const event = "test-event"; 216 | 217 | wrapper = getTestComponent(channelName, event, mockCallback); 218 | 219 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 220 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 221 | 222 | expect(wrapper.vm).toHaveProperty("leave"); 223 | expect(typeof wrapper.vm.leave).toBe("function"); 224 | }); 225 | 226 | it("handles multiple events", async () => { 227 | const mockCallback = vi.fn(); 228 | const channelName = "test-channel"; 229 | const events = ["event1", "event2"]; 230 | 231 | wrapper = getTestComponent(channelName, events, mockCallback); 232 | 233 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 234 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 235 | 236 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 237 | 238 | const channel = echoInstance.private(channelName); 239 | 240 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 241 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 242 | 243 | wrapper.unmount(); 244 | 245 | expect(channel.stopListening).toHaveBeenCalledWith( 246 | events[0], 247 | mockCallback, 248 | ); 249 | expect(channel.stopListening).toHaveBeenCalledWith( 250 | events[1], 251 | mockCallback, 252 | ); 253 | }); 254 | 255 | it("cleans up subscriptions on unmount", async () => { 256 | const mockCallback = vi.fn(); 257 | const channelName = "test-channel"; 258 | const event = "test-event"; 259 | 260 | wrapper = getTestComponent(channelName, event, mockCallback); 261 | 262 | expect(echoInstance.private).toHaveBeenCalled(); 263 | 264 | wrapper.unmount(); 265 | 266 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 267 | }); 268 | 269 | it("won't subscribe multiple times to the same channel", async () => { 270 | const mockCallback = vi.fn(); 271 | const channelName = "test-channel"; 272 | const event = "test-event"; 273 | 274 | wrapper = getTestComponent(channelName, event, mockCallback); 275 | 276 | const wrapper2 = getTestComponent(channelName, event, mockCallback); 277 | 278 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 279 | 280 | wrapper.unmount(); 281 | 282 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 283 | 284 | wrapper2.unmount(); 285 | 286 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 287 | }); 288 | 289 | it("will register callbacks for events", async () => { 290 | const mockCallback = vi.fn(); 291 | const channelName = "test-channel"; 292 | const event = "test-event"; 293 | 294 | wrapper = getTestComponent(channelName, event, mockCallback); 295 | 296 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 297 | 298 | expect(echoInstance.private(channelName).listen).toHaveBeenCalledWith( 299 | event, 300 | mockCallback, 301 | ); 302 | }); 303 | 304 | it("can leave a channel", async () => { 305 | const mockCallback = vi.fn(); 306 | const channelName = "test-channel"; 307 | const event = "test-event"; 308 | 309 | wrapper = getTestComponent(channelName, event, mockCallback); 310 | 311 | wrapper.vm.leaveChannel(); 312 | 313 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 314 | "private-" + channelName, 315 | ); 316 | }); 317 | 318 | it("can leave all channel variations", async () => { 319 | const mockCallback = vi.fn(); 320 | const channelName = "test-channel"; 321 | const event = "test-event"; 322 | 323 | wrapper = getTestComponent(channelName, event, mockCallback); 324 | 325 | wrapper.vm.leave(); 326 | 327 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 328 | }); 329 | 330 | it("can connect to a public channel", async () => { 331 | const mockCallback = vi.fn(); 332 | const channelName = "test-channel"; 333 | const event = "test-event"; 334 | 335 | wrapper = getTestComponent( 336 | channelName, 337 | event, 338 | mockCallback, 339 | [], 340 | "public", 341 | ); 342 | 343 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 344 | 345 | wrapper.vm.leaveChannel(); 346 | 347 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 348 | }); 349 | 350 | it("listen method adds event listeners", async () => { 351 | const mockCallback = vi.fn(); 352 | const channelName = "test-channel"; 353 | const event = "test-event"; 354 | 355 | wrapper = getTestComponent(channelName, event, mockCallback); 356 | const mockChannel = echoInstance.private(channelName); 357 | 358 | expect(mockChannel.listen).toHaveBeenCalledWith(event, mockCallback); 359 | 360 | wrapper.vm.stopListening(); 361 | 362 | expect(mockChannel.stopListening).toHaveBeenCalledWith( 363 | event, 364 | mockCallback, 365 | ); 366 | 367 | wrapper.vm.listen(); 368 | 369 | expect(mockChannel.listen).toHaveBeenCalledWith(event, mockCallback); 370 | }); 371 | 372 | it("listen method is a no-op when already listening", async () => { 373 | const mockCallback = vi.fn(); 374 | const channelName = "test-channel"; 375 | const event = "test-event"; 376 | 377 | wrapper = getTestComponent(channelName, event, mockCallback); 378 | const mockChannel = echoInstance.private(channelName); 379 | 380 | wrapper.vm.listen(); 381 | 382 | expect(mockChannel.listen).toHaveBeenCalledTimes(1); 383 | }); 384 | 385 | it("stopListening method removes event listeners", async () => { 386 | const mockCallback = vi.fn(); 387 | const channelName = "test-channel"; 388 | const event = "test-event"; 389 | 390 | wrapper = getTestComponent(channelName, event, mockCallback); 391 | const mockChannel = echoInstance.private(channelName); 392 | 393 | wrapper.vm.stopListening(); 394 | 395 | expect(mockChannel.stopListening).toHaveBeenCalledWith( 396 | event, 397 | mockCallback, 398 | ); 399 | }); 400 | 401 | it("stopListening method is a no-op when not listening", async () => { 402 | const mockCallback = vi.fn(); 403 | const channelName = "test-channel"; 404 | const event = "test-event"; 405 | 406 | wrapper = getTestComponent(channelName, event, mockCallback); 407 | const mockChannel = echoInstance.private(channelName); 408 | 409 | wrapper.vm.stopListening(); 410 | wrapper.vm.stopListening(); 411 | 412 | expect(mockChannel.stopListening).toHaveBeenCalledTimes(1); 413 | }); 414 | 415 | it("listen and stopListening work with multiple events", async () => { 416 | const mockCallback = vi.fn(); 417 | const channelName = "test-channel"; 418 | const events = ["event1", "event2"]; 419 | 420 | wrapper = getTestComponent(channelName, events, mockCallback); 421 | const mockChannel = echoInstance.private(channelName); 422 | 423 | events.forEach((event) => { 424 | expect(mockChannel.listen).toHaveBeenCalledWith( 425 | event, 426 | mockCallback, 427 | ); 428 | }); 429 | 430 | wrapper.vm.stopListening(); 431 | wrapper.vm.listen(); 432 | 433 | events.forEach((event) => { 434 | expect(mockChannel.listen).toHaveBeenCalledWith( 435 | event, 436 | mockCallback, 437 | ); 438 | }); 439 | 440 | wrapper.vm.stopListening(); 441 | 442 | events.forEach((event) => { 443 | expect(mockChannel.stopListening).toHaveBeenCalledWith( 444 | event, 445 | mockCallback, 446 | ); 447 | }); 448 | }); 449 | 450 | it("events and listeners are optional", async () => { 451 | const channelName = "test-channel"; 452 | 453 | wrapper = getTestComponent(channelName, undefined, undefined); 454 | 455 | expect(wrapper.vm.channel).not.toBeNull(); 456 | }); 457 | }); 458 | 459 | describe("useEchoPublic hook", async () => { 460 | let echoInstance: Echo<"null">; 461 | let wrapper: ReturnType; 462 | 463 | beforeEach(async () => { 464 | vi.resetModules(); 465 | 466 | echoInstance = new Echo({ 467 | broadcaster: "null", 468 | }); 469 | }); 470 | 471 | afterEach(() => { 472 | wrapper.unmount(); 473 | vi.clearAllMocks(); 474 | }); 475 | 476 | it("subscribes to a public channel and listens for events", async () => { 477 | const mockCallback = vi.fn(); 478 | const channelName = "test-channel"; 479 | const event = "test-event"; 480 | 481 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 482 | 483 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 484 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 485 | 486 | expect(wrapper.vm).toHaveProperty("leave"); 487 | expect(typeof wrapper.vm.leave).toBe("function"); 488 | }); 489 | 490 | it("handles multiple events", async () => { 491 | const mockCallback = vi.fn(); 492 | const channelName = "test-channel"; 493 | const events = ["event1", "event2"]; 494 | 495 | wrapper = getPublicTestComponent(channelName, events, mockCallback); 496 | 497 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 498 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 499 | 500 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 501 | 502 | const channel = echoInstance.channel(channelName); 503 | 504 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 505 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 506 | 507 | wrapper.unmount(); 508 | 509 | expect(channel.stopListening).toHaveBeenCalledWith( 510 | events[0], 511 | mockCallback, 512 | ); 513 | expect(channel.stopListening).toHaveBeenCalledWith( 514 | events[1], 515 | mockCallback, 516 | ); 517 | }); 518 | 519 | it("cleans up subscriptions on unmount", async () => { 520 | const mockCallback = vi.fn(); 521 | const channelName = "test-channel"; 522 | const event = "test-event"; 523 | 524 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 525 | 526 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 527 | 528 | wrapper.unmount(); 529 | 530 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 531 | }); 532 | 533 | it("won't subscribe multiple times to the same channel", async () => { 534 | const mockCallback = vi.fn(); 535 | const channelName = "test-channel"; 536 | const event = "test-event"; 537 | 538 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 539 | 540 | const wrapper2 = getPublicTestComponent( 541 | channelName, 542 | event, 543 | mockCallback, 544 | ); 545 | 546 | expect(echoInstance.channel).toHaveBeenCalledTimes(1); 547 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 548 | 549 | wrapper.unmount(); 550 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 551 | 552 | wrapper2.unmount(); 553 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 554 | }); 555 | 556 | it("can leave a channel", async () => { 557 | const mockCallback = vi.fn(); 558 | const channelName = "test-channel"; 559 | const event = "test-event"; 560 | 561 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 562 | 563 | wrapper.vm.leaveChannel(); 564 | 565 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 566 | }); 567 | 568 | it("can leave all channel variations", async () => { 569 | const mockCallback = vi.fn(); 570 | const channelName = "test-channel"; 571 | const event = "test-event"; 572 | 573 | wrapper = getPublicTestComponent(channelName, event, mockCallback); 574 | 575 | wrapper.vm.leave(); 576 | 577 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 578 | }); 579 | 580 | it("events and listeners are optional", async () => { 581 | const channelName = "test-channel"; 582 | 583 | wrapper = getPublicTestComponent(channelName, undefined, undefined); 584 | 585 | expect(wrapper.vm.channel).not.toBeNull(); 586 | }); 587 | }); 588 | 589 | describe("useEchoPresence hook", async () => { 590 | let echoInstance: Echo<"null">; 591 | let wrapper: ReturnType; 592 | 593 | beforeEach(async () => { 594 | vi.resetModules(); 595 | 596 | echoInstance = new Echo({ 597 | broadcaster: "null", 598 | }); 599 | }); 600 | 601 | afterEach(() => { 602 | wrapper.unmount(); 603 | vi.clearAllMocks(); 604 | }); 605 | 606 | it("subscribes to a presence channel and listens for events", async () => { 607 | const mockCallback = vi.fn(); 608 | const channelName = "test-channel"; 609 | const event = "test-event"; 610 | 611 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 612 | 613 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 614 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 615 | 616 | expect(wrapper.vm).toHaveProperty("leave"); 617 | expect(typeof wrapper.vm.leave).toBe("function"); 618 | 619 | expect(wrapper.vm).toHaveProperty("channel"); 620 | expect(wrapper.vm.channel).not.toBeNull(); 621 | expect(typeof wrapper.vm.channel().here).toBe("function"); 622 | expect(typeof wrapper.vm.channel().joining).toBe("function"); 623 | expect(typeof wrapper.vm.channel().leaving).toBe("function"); 624 | expect(typeof wrapper.vm.channel().whisper).toBe("function"); 625 | }); 626 | 627 | it("handles multiple events", async () => { 628 | const mockCallback = vi.fn(); 629 | const channelName = "test-channel"; 630 | const events = ["event1", "event2"]; 631 | 632 | wrapper = getPresenceTestComponent(channelName, events, mockCallback); 633 | 634 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 635 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 636 | 637 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 638 | 639 | const channel = echoInstance.join(channelName); 640 | 641 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 642 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 643 | 644 | wrapper.unmount(); 645 | 646 | expect(channel.stopListening).toHaveBeenCalledWith( 647 | events[0], 648 | mockCallback, 649 | ); 650 | expect(channel.stopListening).toHaveBeenCalledWith( 651 | events[1], 652 | mockCallback, 653 | ); 654 | }); 655 | 656 | it("cleans up subscriptions on unmount", async () => { 657 | const mockCallback = vi.fn(); 658 | const channelName = "test-channel"; 659 | const event = "test-event"; 660 | 661 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 662 | 663 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 664 | 665 | wrapper.unmount(); 666 | 667 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 668 | `presence-${channelName}`, 669 | ); 670 | }); 671 | 672 | it("won't subscribe multiple times to the same channel", async () => { 673 | const mockCallback = vi.fn(); 674 | const channelName = "test-channel"; 675 | const event = "test-event"; 676 | 677 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 678 | 679 | const wrapper2 = getPresenceTestComponent( 680 | channelName, 681 | event, 682 | mockCallback, 683 | ); 684 | 685 | expect(echoInstance.join).toHaveBeenCalledTimes(1); 686 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 687 | 688 | wrapper.unmount(); 689 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 690 | 691 | wrapper2.unmount(); 692 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 693 | `presence-${channelName}`, 694 | ); 695 | }); 696 | 697 | it("can leave a channel", async () => { 698 | const mockCallback = vi.fn(); 699 | const channelName = "test-channel"; 700 | const event = "test-event"; 701 | 702 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 703 | 704 | wrapper.vm.leaveChannel(); 705 | 706 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 707 | `presence-${channelName}`, 708 | ); 709 | }); 710 | 711 | it("can leave all channel variations", async () => { 712 | const mockCallback = vi.fn(); 713 | const channelName = "test-channel"; 714 | const event = "test-event"; 715 | 716 | wrapper = getPresenceTestComponent(channelName, event, mockCallback); 717 | 718 | wrapper.vm.leave(); 719 | 720 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 721 | }); 722 | 723 | it("events and listeners are optional", async () => { 724 | const channelName = "test-channel"; 725 | 726 | wrapper = getPresenceTestComponent(channelName, undefined, undefined); 727 | 728 | expect(wrapper.vm.channel).not.toBeNull(); 729 | }); 730 | }); 731 | 732 | describe("useEchoNotification hook", async () => { 733 | let echoInstance: Echo<"null">; 734 | let wrapper: ReturnType; 735 | 736 | beforeEach(async () => { 737 | vi.resetModules(); 738 | 739 | echoInstance = new Echo({ 740 | broadcaster: "null", 741 | }); 742 | }); 743 | 744 | afterEach(() => { 745 | wrapper.unmount(); 746 | vi.clearAllMocks(); 747 | }); 748 | 749 | it("subscribes to a private channel and listens for notifications", async () => { 750 | const mockCallback = vi.fn(); 751 | const channelName = "test-channel"; 752 | 753 | wrapper = getNotificationTestComponent( 754 | channelName, 755 | mockCallback, 756 | undefined, 757 | ); 758 | 759 | expect(wrapper.vm).toHaveProperty("leaveChannel"); 760 | expect(typeof wrapper.vm.leaveChannel).toBe("function"); 761 | 762 | expect(wrapper.vm).toHaveProperty("leave"); 763 | expect(typeof wrapper.vm.leave).toBe("function"); 764 | 765 | expect(wrapper.vm).toHaveProperty("listen"); 766 | expect(typeof wrapper.vm.listen).toBe("function"); 767 | 768 | expect(wrapper.vm).toHaveProperty("stopListening"); 769 | expect(typeof wrapper.vm.stopListening).toBe("function"); 770 | }); 771 | 772 | it("sets up a notification listener on a channel", async () => { 773 | const mockCallback = vi.fn(); 774 | const channelName = "test-channel"; 775 | 776 | wrapper = getNotificationTestComponent( 777 | channelName, 778 | mockCallback, 779 | undefined, 780 | ); 781 | 782 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 783 | 784 | const channel = echoInstance.private(channelName); 785 | expect(channel.notification).toHaveBeenCalled(); 786 | }); 787 | 788 | it("handles notification filtering by event type", async () => { 789 | const mockCallback = vi.fn(); 790 | const channelName = "test-channel"; 791 | const eventType = "specific-type"; 792 | 793 | wrapper = getNotificationTestComponent( 794 | channelName, 795 | mockCallback, 796 | eventType, 797 | ); 798 | 799 | const channel = echoInstance.private(channelName); 800 | expect(channel.notification).toHaveBeenCalled(); 801 | 802 | const notificationCallback = vi.mocked(channel.notification).mock 803 | .calls[0][0]; 804 | 805 | const matchingNotification = { 806 | type: eventType, 807 | data: { message: "test" }, 808 | }; 809 | const nonMatchingNotification = { 810 | type: "other-type", 811 | data: { message: "test" }, 812 | }; 813 | 814 | notificationCallback(matchingNotification); 815 | notificationCallback(nonMatchingNotification); 816 | 817 | expect(mockCallback).toHaveBeenCalledWith(matchingNotification); 818 | expect(mockCallback).toHaveBeenCalledTimes(1); 819 | expect(mockCallback).not.toHaveBeenCalledWith(nonMatchingNotification); 820 | }); 821 | 822 | it("handles multiple notification event types", async () => { 823 | const mockCallback = vi.fn(); 824 | const channelName = "test-channel"; 825 | const events = ["type1", "type2"]; 826 | 827 | wrapper = getNotificationTestComponent( 828 | channelName, 829 | mockCallback, 830 | events, 831 | ); 832 | 833 | const channel = echoInstance.private(channelName); 834 | expect(channel.notification).toHaveBeenCalled(); 835 | 836 | const notificationCallback = vi.mocked(channel.notification).mock 837 | .calls[0][0]; 838 | 839 | const notification1 = { type: events[0], data: {} }; 840 | const notification2 = { type: events[1], data: {} }; 841 | const notification3 = { type: "type3", data: {} }; 842 | 843 | notificationCallback(notification1); 844 | notificationCallback(notification2); 845 | notificationCallback(notification3); 846 | 847 | expect(mockCallback).toHaveBeenCalledWith(notification1); 848 | expect(mockCallback).toHaveBeenCalledWith(notification2); 849 | expect(mockCallback).toHaveBeenCalledTimes(2); 850 | expect(mockCallback).not.toHaveBeenCalledWith(notification3); 851 | }); 852 | 853 | it("handles dotted and slashed notification event types", async () => { 854 | const mockCallback = vi.fn(); 855 | const channelName = "test-channel"; 856 | const events = [ 857 | "App.Notifications.First", 858 | "App\\Notifications\\Second", 859 | ]; 860 | 861 | wrapper = getNotificationTestComponent( 862 | channelName, 863 | mockCallback, 864 | events, 865 | ); 866 | 867 | const channel = echoInstance.private(channelName); 868 | expect(channel.notification).toHaveBeenCalled(); 869 | 870 | const notificationCallback = vi.mocked(channel.notification).mock 871 | .calls[0][0]; 872 | 873 | const notification1 = { 874 | type: "App\\Notifications\\First", 875 | data: {}, 876 | }; 877 | const notification2 = { 878 | type: "App\\Notifications\\Second", 879 | data: {}, 880 | }; 881 | 882 | notificationCallback(notification1); 883 | notificationCallback(notification2); 884 | 885 | expect(mockCallback).toHaveBeenCalledWith(notification1); 886 | expect(mockCallback).toHaveBeenCalledWith(notification2); 887 | expect(mockCallback).toHaveBeenCalledTimes(2); 888 | }); 889 | 890 | it("accepts all notifications when no event types specified", async () => { 891 | const mockCallback = vi.fn(); 892 | const channelName = "test-channel"; 893 | 894 | wrapper = getNotificationTestComponent( 895 | channelName, 896 | mockCallback, 897 | undefined, 898 | ); 899 | 900 | const channel = echoInstance.private(channelName); 901 | expect(channel.notification).toHaveBeenCalled(); 902 | 903 | const notificationCallback = vi.mocked(channel.notification).mock 904 | .calls[0][0]; 905 | 906 | const notification1 = { type: "type1", data: {} }; 907 | const notification2 = { type: "type2", data: {} }; 908 | 909 | notificationCallback(notification1); 910 | notificationCallback(notification2); 911 | 912 | expect(mockCallback).toHaveBeenCalledWith(notification1); 913 | expect(mockCallback).toHaveBeenCalledWith(notification2); 914 | expect(mockCallback).toHaveBeenCalledTimes(2); 915 | }); 916 | 917 | it("cleans up subscriptions on unmount", async () => { 918 | const mockCallback = vi.fn(); 919 | const channelName = "test-channel"; 920 | 921 | wrapper = getNotificationTestComponent( 922 | channelName, 923 | mockCallback, 924 | undefined, 925 | ); 926 | 927 | const channel = echoInstance.private(channelName); 928 | 929 | wrapper.unmount(); 930 | 931 | expect(channel.stopListeningForNotification).toHaveBeenCalled(); 932 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 933 | `private-${channelName}`, 934 | ); 935 | }); 936 | 937 | it("won't subscribe multiple times to the same channel", async () => { 938 | const mockCallback = vi.fn(); 939 | const channelName = "test-channel"; 940 | 941 | wrapper = getNotificationTestComponent( 942 | channelName, 943 | mockCallback, 944 | undefined, 945 | ); 946 | 947 | const wrapper2 = getNotificationTestComponent( 948 | channelName, 949 | mockCallback, 950 | undefined, 951 | ); 952 | 953 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 954 | 955 | wrapper.unmount(); 956 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 957 | 958 | wrapper2.unmount(); 959 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 960 | `private-${channelName}`, 961 | ); 962 | }); 963 | 964 | it("can leave a channel", async () => { 965 | const mockCallback = vi.fn(); 966 | const channelName = "test-channel"; 967 | 968 | wrapper = getNotificationTestComponent( 969 | channelName, 970 | mockCallback, 971 | undefined, 972 | ); 973 | 974 | wrapper.vm.leaveChannel(); 975 | 976 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 977 | `private-${channelName}`, 978 | ); 979 | }); 980 | 981 | it("can leave all channel variations", async () => { 982 | const mockCallback = vi.fn(); 983 | const channelName = "test-channel"; 984 | 985 | wrapper = getNotificationTestComponent( 986 | channelName, 987 | mockCallback, 988 | undefined, 989 | ); 990 | 991 | wrapper.vm.leave(); 992 | 993 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 994 | }); 995 | 996 | it("can manually start and stop listening", async () => { 997 | const mockCallback = vi.fn(); 998 | const channelName = "test-channel"; 999 | 1000 | wrapper = getNotificationTestComponent( 1001 | channelName, 1002 | mockCallback, 1003 | undefined, 1004 | ); 1005 | 1006 | const channel = echoInstance.private(channelName); 1007 | expect(channel.notification).toHaveBeenCalledTimes(1); 1008 | 1009 | wrapper.vm.stopListening(); 1010 | wrapper.vm.listen(); 1011 | 1012 | expect(channel.notification).toHaveBeenCalledTimes(1); 1013 | }); 1014 | 1015 | it("stopListening prevents new notification listeners", async () => { 1016 | const mockCallback = vi.fn(); 1017 | const channelName = "test-channel"; 1018 | 1019 | wrapper = getNotificationTestComponent( 1020 | channelName, 1021 | mockCallback, 1022 | undefined, 1023 | ); 1024 | 1025 | wrapper.vm.stopListening(); 1026 | 1027 | expect(wrapper.vm.stopListening).toBeDefined(); 1028 | expect(typeof wrapper.vm.stopListening).toBe("function"); 1029 | }); 1030 | 1031 | it("callback and events are optional", async () => { 1032 | const channelName = "test-channel"; 1033 | 1034 | wrapper = getNotificationTestComponent( 1035 | channelName, 1036 | undefined, 1037 | undefined, 1038 | ); 1039 | 1040 | expect(wrapper.vm.channel).not.toBeNull(); 1041 | }); 1042 | }); 1043 | -------------------------------------------------------------------------------- /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 | stopListeningForNotification: vi.fn(), 15 | }; 16 | 17 | const mockPublicChannel = { 18 | leaveChannel: vi.fn(), 19 | listen: vi.fn(), 20 | stopListening: vi.fn(), 21 | }; 22 | 23 | const mockPresenceChannel = { 24 | leaveChannel: vi.fn(), 25 | listen: vi.fn(), 26 | stopListening: vi.fn(), 27 | here: vi.fn(), 28 | joining: vi.fn(), 29 | leaving: vi.fn(), 30 | whisper: vi.fn(), 31 | }; 32 | 33 | const Echo = vi.fn(); 34 | 35 | Echo.prototype.private = vi.fn(() => mockPrivateChannel); 36 | Echo.prototype.channel = vi.fn(() => mockPublicChannel); 37 | Echo.prototype.encryptedPrivate = vi.fn(); 38 | Echo.prototype.listen = vi.fn(); 39 | Echo.prototype.leave = vi.fn(); 40 | Echo.prototype.leaveChannel = vi.fn(); 41 | Echo.prototype.leaveAllChannels = vi.fn(); 42 | Echo.prototype.join = vi.fn(() => mockPresenceChannel); 43 | 44 | return { default: Echo }; 45 | }); 46 | 47 | describe("without echo configured", async () => { 48 | beforeEach(() => { 49 | vi.resetModules(); 50 | }); 51 | 52 | it("throws error when Echo is not configured", async () => { 53 | const echoModule = await getEchoModule(); 54 | const mockCallback = vi.fn(); 55 | const channelName = "test-channel"; 56 | const event = "test-event"; 57 | 58 | expect(() => 59 | renderHook(() => 60 | echoModule.useEcho( 61 | channelName, 62 | event, 63 | mockCallback, 64 | [], 65 | "private", 66 | ), 67 | ), 68 | ).toThrow("Echo has not been configured"); 69 | }); 70 | }); 71 | 72 | describe("useEcho hook", async () => { 73 | let echoModule: typeof import("../src/hooks/use-echo"); 74 | let configModule: typeof import("../src/config/index"); 75 | let echoInstance: Echo<"null">; 76 | 77 | beforeEach(async () => { 78 | vi.resetModules(); 79 | 80 | echoInstance = new Echo({ 81 | broadcaster: "null", 82 | }); 83 | 84 | echoModule = await getEchoModule(); 85 | configModule = await getConfigModule(); 86 | 87 | configModule.configureEcho({ 88 | broadcaster: "null", 89 | }); 90 | }); 91 | 92 | afterEach(() => { 93 | vi.clearAllMocks(); 94 | }); 95 | 96 | it("subscribes to a channel and listens for events", async () => { 97 | const mockCallback = vi.fn(); 98 | const channelName = "test-channel"; 99 | const event = "test-event"; 100 | 101 | const { result } = renderHook(() => 102 | echoModule.useEcho(channelName, event, mockCallback), 103 | ); 104 | 105 | expect(result.current).toHaveProperty("leaveChannel"); 106 | expect(typeof result.current.leave).toBe("function"); 107 | 108 | expect(result.current).toHaveProperty("leave"); 109 | expect(typeof result.current.leaveChannel).toBe("function"); 110 | }); 111 | 112 | it("handles multiple events", async () => { 113 | const mockCallback = vi.fn(); 114 | const channelName = "test-channel"; 115 | const events = ["event1", "event2"]; 116 | 117 | const { result, unmount } = renderHook(() => 118 | echoModule.useEcho(channelName, events, mockCallback), 119 | ); 120 | 121 | expect(result.current).toHaveProperty("leaveChannel"); 122 | 123 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 124 | 125 | const channel = echoInstance.private(channelName); 126 | 127 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 128 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 129 | 130 | expect(() => unmount()).not.toThrow(); 131 | 132 | expect(channel.stopListening).toHaveBeenCalledWith( 133 | events[0], 134 | mockCallback, 135 | ); 136 | expect(channel.stopListening).toHaveBeenCalledWith( 137 | events[1], 138 | mockCallback, 139 | ); 140 | }); 141 | 142 | it("cleans up subscriptions on unmount", async () => { 143 | const mockCallback = vi.fn(); 144 | const channelName = "test-channel"; 145 | const event = "test-event"; 146 | 147 | const { unmount } = renderHook(() => 148 | echoModule.useEcho(channelName, event, mockCallback), 149 | ); 150 | 151 | expect(echoInstance.private).toHaveBeenCalled(); 152 | 153 | expect(() => unmount()).not.toThrow(); 154 | 155 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 156 | }); 157 | 158 | it("won't subscribe multiple times to the same channel", async () => { 159 | const mockCallback = vi.fn(); 160 | const channelName = "test-channel"; 161 | const event = "test-event"; 162 | 163 | const { unmount: unmount1 } = renderHook(() => 164 | echoModule.useEcho(channelName, event, mockCallback), 165 | ); 166 | 167 | const { unmount: unmount2 } = renderHook(() => 168 | echoModule.useEcho(channelName, event, mockCallback), 169 | ); 170 | 171 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 172 | 173 | expect(() => unmount1()).not.toThrow(); 174 | 175 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 176 | 177 | expect(() => unmount2()).not.toThrow(); 178 | 179 | expect(echoInstance.leaveChannel).toHaveBeenCalled(); 180 | }); 181 | 182 | it("will register callbacks for events", async () => { 183 | const mockCallback = vi.fn(); 184 | const channelName = "test-channel"; 185 | const event = "test-event"; 186 | 187 | const { unmount } = renderHook(() => 188 | echoModule.useEcho(channelName, event, mockCallback), 189 | ); 190 | 191 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 192 | 193 | expect(echoInstance.private(channelName).listen).toHaveBeenCalledWith( 194 | event, 195 | mockCallback, 196 | ); 197 | }); 198 | 199 | it("can leave a channel", async () => { 200 | const mockCallback = vi.fn(); 201 | const channelName = "test-channel"; 202 | const event = "test-event"; 203 | 204 | const { result } = renderHook(() => 205 | echoModule.useEcho(channelName, event, mockCallback), 206 | ); 207 | 208 | result.current.leaveChannel(); 209 | 210 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 211 | "private-" + channelName, 212 | ); 213 | }); 214 | 215 | it("can leave all channel variations", async () => { 216 | const mockCallback = vi.fn(); 217 | const channelName = "test-channel"; 218 | const event = "test-event"; 219 | 220 | const { result } = renderHook(() => 221 | echoModule.useEcho(channelName, event, mockCallback), 222 | ); 223 | 224 | result.current.leave(); 225 | 226 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 227 | }); 228 | 229 | it("can connect to a public channel", async () => { 230 | const mockCallback = vi.fn(); 231 | const channelName = "test-channel"; 232 | const event = "test-event"; 233 | 234 | const { result } = renderHook(() => 235 | echoModule.useEcho(channelName, event, mockCallback, [], "public"), 236 | ); 237 | 238 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 239 | 240 | result.current.leaveChannel(); 241 | 242 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 243 | }); 244 | 245 | it("can manually start listening to events", async () => { 246 | const mockCallback = vi.fn(); 247 | const channelName = "test-channel"; 248 | const event = "test-event"; 249 | 250 | const { result } = renderHook(() => 251 | echoModule.useEcho(channelName, event, mockCallback), 252 | ); 253 | 254 | const channel = echoInstance.private(channelName); 255 | 256 | expect(channel.listen).toHaveBeenCalledWith(event, mockCallback); 257 | 258 | result.current.stopListening(); 259 | 260 | expect(channel.stopListening).toHaveBeenCalledWith(event, mockCallback); 261 | 262 | result.current.listen(); 263 | 264 | expect(channel.listen).toHaveBeenCalledWith(event, mockCallback); 265 | }); 266 | 267 | it("can manually stop listening to events", async () => { 268 | const mockCallback = vi.fn(); 269 | const channelName = "test-channel"; 270 | const event = "test-event"; 271 | 272 | const { result } = renderHook(() => 273 | echoModule.useEcho(channelName, event, mockCallback), 274 | ); 275 | 276 | result.current.stopListening(); 277 | 278 | const channel = echoInstance.private(channelName); 279 | expect(channel.stopListening).toHaveBeenCalledWith(event, mockCallback); 280 | }); 281 | 282 | it("stopListening is a no-op when not listening", async () => { 283 | const mockCallback = vi.fn(); 284 | const channelName = "test-channel"; 285 | const event = "test-event"; 286 | 287 | const { result } = renderHook(() => 288 | echoModule.useEcho(channelName, event, mockCallback), 289 | ); 290 | 291 | result.current.stopListening(); 292 | result.current.stopListening(); 293 | 294 | const channel = echoInstance.private(channelName); 295 | expect(channel.stopListening).toHaveBeenCalledTimes(1); 296 | }); 297 | 298 | it("listen is a no-op when already listening", async () => { 299 | const mockCallback = vi.fn(); 300 | const channelName = "test-channel"; 301 | const event = "test-event"; 302 | 303 | const { result } = renderHook(() => 304 | echoModule.useEcho(channelName, event, mockCallback), 305 | ); 306 | 307 | result.current.listen(); 308 | 309 | const channel = echoInstance.private(channelName); 310 | expect(channel.listen).toHaveBeenCalledTimes(1); 311 | }); 312 | 313 | it("events and listeners are optional", async () => { 314 | const channelName = "test-channel"; 315 | 316 | const { result } = renderHook(() => echoModule.useEcho(channelName)); 317 | 318 | expect(result.current).toHaveProperty("channel"); 319 | expect(result.current.channel).not.toBeNull(); 320 | }); 321 | }); 322 | 323 | describe("useEchoModel hook", async () => { 324 | let echoModule: typeof import("../src/hooks/use-echo"); 325 | let configModule: typeof import("../src/config/index"); 326 | let echoInstance: Echo<"null">; 327 | 328 | beforeEach(async () => { 329 | vi.resetModules(); 330 | 331 | echoInstance = new Echo({ 332 | broadcaster: "null", 333 | }); 334 | 335 | echoModule = await getEchoModule(); 336 | configModule = await getConfigModule(); 337 | 338 | configModule.configureEcho({ 339 | broadcaster: "null", 340 | }); 341 | }); 342 | 343 | afterEach(() => { 344 | vi.clearAllMocks(); 345 | }); 346 | 347 | it("subscribes to model channel and listens for model events", async () => { 348 | const mockCallback = vi.fn(); 349 | const model = "App.Models.User"; 350 | const identifier = "123"; 351 | const event = "UserCreated"; 352 | 353 | const { result } = renderHook(() => 354 | echoModule.useEchoModel( 355 | model, 356 | identifier, 357 | event, 358 | mockCallback, 359 | ), 360 | ); 361 | 362 | expect(result.current).toHaveProperty("leaveChannel"); 363 | expect(typeof result.current.leave).toBe("function"); 364 | expect(result.current).toHaveProperty("leave"); 365 | expect(typeof result.current.leaveChannel).toBe("function"); 366 | }); 367 | 368 | it("handles multiple model events", async () => { 369 | const mockCallback = vi.fn(); 370 | const model = "App.Models.User"; 371 | const identifier = "123"; 372 | const events = ["UserCreated", "UserUpdated"]; 373 | 374 | const { result, unmount } = renderHook(() => 375 | echoModule.useEchoModel( 376 | model, 377 | identifier, 378 | ["UserCreated", "UserUpdated"], 379 | mockCallback, 380 | ), 381 | ); 382 | 383 | expect(result.current).toHaveProperty("leaveChannel"); 384 | 385 | const expectedChannelName = `${model}.${identifier}`; 386 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 387 | 388 | const channel = echoInstance.private(expectedChannelName); 389 | 390 | expect(channel.listen).toHaveBeenCalledWith( 391 | `.${events[0]}`, 392 | mockCallback, 393 | ); 394 | expect(channel.listen).toHaveBeenCalledWith( 395 | `.${events[1]}`, 396 | mockCallback, 397 | ); 398 | 399 | expect(() => unmount()).not.toThrow(); 400 | 401 | expect(channel.stopListening).toHaveBeenCalledWith( 402 | `.${events[0]}`, 403 | mockCallback, 404 | ); 405 | expect(channel.stopListening).toHaveBeenCalledWith( 406 | `.${events[1]}`, 407 | mockCallback, 408 | ); 409 | }); 410 | 411 | it("cleans up subscriptions on unmount", async () => { 412 | const mockCallback = vi.fn(); 413 | const model = "App.Models.User"; 414 | const identifier = "123"; 415 | const event = "UserCreated"; 416 | 417 | const { unmount } = renderHook(() => 418 | echoModule.useEchoModel( 419 | model, 420 | identifier, 421 | event, 422 | mockCallback, 423 | ), 424 | ); 425 | 426 | const expectedChannelName = `${model}.${identifier}`; 427 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 428 | 429 | expect(() => unmount()).not.toThrow(); 430 | 431 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 432 | `private-${expectedChannelName}`, 433 | ); 434 | }); 435 | 436 | it("won't subscribe multiple times to the same model channel", async () => { 437 | const mockCallback = vi.fn(); 438 | const model = "App.Models.User"; 439 | const identifier = "123"; 440 | const event = "UserCreated"; 441 | 442 | const { unmount: unmount1 } = renderHook(() => 443 | echoModule.useEchoModel( 444 | model, 445 | identifier, 446 | event, 447 | mockCallback, 448 | ), 449 | ); 450 | 451 | const { unmount: unmount2 } = renderHook(() => 452 | echoModule.useEchoModel( 453 | model, 454 | identifier, 455 | event, 456 | mockCallback, 457 | ), 458 | ); 459 | 460 | const expectedChannelName = `${model}.${identifier}`; 461 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 462 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 463 | 464 | expect(() => unmount1()).not.toThrow(); 465 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 466 | 467 | expect(() => unmount2()).not.toThrow(); 468 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 469 | `private-${expectedChannelName}`, 470 | ); 471 | }); 472 | 473 | it("can leave a model channel", async () => { 474 | const mockCallback = vi.fn(); 475 | const model = "App.Models.User"; 476 | const identifier = "123"; 477 | const event = "UserCreated"; 478 | 479 | const { result } = renderHook(() => 480 | echoModule.useEchoModel( 481 | model, 482 | identifier, 483 | event, 484 | mockCallback, 485 | ), 486 | ); 487 | 488 | result.current.leaveChannel(); 489 | 490 | const expectedChannelName = `${model}.${identifier}`; 491 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 492 | `private-${expectedChannelName}`, 493 | ); 494 | }); 495 | 496 | it("can leave all model channel variations", async () => { 497 | const mockCallback = vi.fn(); 498 | const model = "App.Models.User"; 499 | const identifier = "123"; 500 | const event = "UserCreated"; 501 | 502 | const { result } = renderHook(() => 503 | echoModule.useEchoModel( 504 | model, 505 | identifier, 506 | event, 507 | mockCallback, 508 | ), 509 | ); 510 | 511 | result.current.leave(); 512 | 513 | const expectedChannelName = `${model}.${identifier}`; 514 | expect(echoInstance.leave).toHaveBeenCalledWith(expectedChannelName); 515 | }); 516 | 517 | it("handles model events with dots in the name", async () => { 518 | const mockCallback = vi.fn(); 519 | const model = "App.Models.User.Profile"; 520 | const identifier = "123"; 521 | const event = "ProfileCreated"; 522 | 523 | const { result } = renderHook(() => 524 | echoModule.useEchoModel( 525 | model, 526 | identifier, 527 | event, 528 | mockCallback, 529 | ), 530 | ); 531 | 532 | const expectedChannelName = `${model}.${identifier}`; 533 | expect(echoInstance.private).toHaveBeenCalledWith(expectedChannelName); 534 | 535 | const channel = echoInstance.private(expectedChannelName); 536 | expect(channel.listen).toHaveBeenCalledWith(`.${event}`, mockCallback); 537 | }); 538 | 539 | it("events and listeners are optional", async () => { 540 | const model = "App.Models.User.Profile"; 541 | const identifier = "123"; 542 | 543 | const { result } = renderHook(() => 544 | echoModule.useEchoModel(model, identifier), 545 | ); 546 | 547 | expect(result.current).toHaveProperty("channel"); 548 | expect(result.current.channel).not.toBeNull(); 549 | }); 550 | }); 551 | 552 | describe("useEchoPublic hook", async () => { 553 | let echoModule: typeof import("../src/hooks/use-echo"); 554 | let configModule: typeof import("../src/config/index"); 555 | let echoInstance: Echo<"null">; 556 | 557 | beforeEach(async () => { 558 | vi.resetModules(); 559 | 560 | echoInstance = new Echo({ 561 | broadcaster: "null", 562 | }); 563 | 564 | echoModule = await getEchoModule(); 565 | configModule = await getConfigModule(); 566 | 567 | configModule.configureEcho({ 568 | broadcaster: "null", 569 | }); 570 | }); 571 | 572 | afterEach(() => { 573 | vi.clearAllMocks(); 574 | }); 575 | 576 | it("subscribes to a public channel and listens for events", async () => { 577 | const mockCallback = vi.fn(); 578 | const channelName = "test-channel"; 579 | const event = "test-event"; 580 | 581 | const { result } = renderHook(() => 582 | echoModule.useEchoPublic(channelName, event, mockCallback), 583 | ); 584 | 585 | expect(result.current).toHaveProperty("leaveChannel"); 586 | expect(typeof result.current.leave).toBe("function"); 587 | expect(result.current).toHaveProperty("leave"); 588 | expect(typeof result.current.leaveChannel).toBe("function"); 589 | }); 590 | 591 | it("handles multiple events", async () => { 592 | const mockCallback = vi.fn(); 593 | const channelName = "test-channel"; 594 | const events = ["event1", "event2"]; 595 | 596 | const { result, unmount } = renderHook(() => 597 | echoModule.useEchoPublic(channelName, events, mockCallback), 598 | ); 599 | 600 | expect(result.current).toHaveProperty("leaveChannel"); 601 | 602 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 603 | 604 | const channel = echoInstance.channel(channelName); 605 | 606 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 607 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 608 | 609 | expect(() => unmount()).not.toThrow(); 610 | 611 | expect(channel.stopListening).toHaveBeenCalledWith( 612 | events[0], 613 | mockCallback, 614 | ); 615 | expect(channel.stopListening).toHaveBeenCalledWith( 616 | events[1], 617 | mockCallback, 618 | ); 619 | }); 620 | 621 | it("cleans up subscriptions on unmount", async () => { 622 | const mockCallback = vi.fn(); 623 | const channelName = "test-channel"; 624 | const event = "test-event"; 625 | 626 | const { unmount } = renderHook(() => 627 | echoModule.useEchoPublic(channelName, event, mockCallback), 628 | ); 629 | 630 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 631 | 632 | expect(() => unmount()).not.toThrow(); 633 | 634 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 635 | }); 636 | 637 | it("won't subscribe multiple times to the same channel", async () => { 638 | const mockCallback = vi.fn(); 639 | const channelName = "test-channel"; 640 | const event = "test-event"; 641 | 642 | const { unmount: unmount1 } = renderHook(() => 643 | echoModule.useEchoPublic(channelName, event, mockCallback), 644 | ); 645 | 646 | const { unmount: unmount2 } = renderHook(() => 647 | echoModule.useEchoPublic(channelName, event, mockCallback), 648 | ); 649 | 650 | expect(echoInstance.channel).toHaveBeenCalledTimes(1); 651 | expect(echoInstance.channel).toHaveBeenCalledWith(channelName); 652 | 653 | expect(() => unmount1()).not.toThrow(); 654 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 655 | 656 | expect(() => unmount2()).not.toThrow(); 657 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 658 | }); 659 | 660 | it("can leave a channel", async () => { 661 | const mockCallback = vi.fn(); 662 | const channelName = "test-channel"; 663 | const event = "test-event"; 664 | 665 | const { result } = renderHook(() => 666 | echoModule.useEchoPublic(channelName, event, mockCallback), 667 | ); 668 | 669 | result.current.leaveChannel(); 670 | 671 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith(channelName); 672 | }); 673 | 674 | it("can leave all channel variations", async () => { 675 | const mockCallback = vi.fn(); 676 | const channelName = "test-channel"; 677 | const event = "test-event"; 678 | 679 | const { result } = renderHook(() => 680 | echoModule.useEchoPublic(channelName, event, mockCallback), 681 | ); 682 | 683 | result.current.leave(); 684 | 685 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 686 | }); 687 | 688 | it("events and listeners are optional", async () => { 689 | const channelName = "test-channel"; 690 | 691 | const { result } = renderHook(() => 692 | echoModule.useEchoPublic(channelName), 693 | ); 694 | 695 | expect(result.current).toHaveProperty("channel"); 696 | expect(result.current.channel).not.toBeNull(); 697 | }); 698 | }); 699 | 700 | describe("useEchoPresence hook", async () => { 701 | let echoModule: typeof import("../src/hooks/use-echo"); 702 | let configModule: typeof import("../src/config/index"); 703 | let echoInstance: Echo<"null">; 704 | 705 | beforeEach(async () => { 706 | vi.resetModules(); 707 | 708 | echoInstance = new Echo({ 709 | broadcaster: "null", 710 | }); 711 | 712 | echoModule = await getEchoModule(); 713 | configModule = await getConfigModule(); 714 | 715 | configModule.configureEcho({ 716 | broadcaster: "null", 717 | }); 718 | }); 719 | 720 | afterEach(() => { 721 | vi.clearAllMocks(); 722 | }); 723 | 724 | it("subscribes to a presence channel and listens for events", async () => { 725 | const mockCallback = vi.fn(); 726 | const channelName = "test-channel"; 727 | const event = "test-event"; 728 | 729 | const { result } = renderHook(() => 730 | echoModule.useEchoPresence(channelName, event, mockCallback), 731 | ); 732 | 733 | expect(result.current).toHaveProperty("leaveChannel"); 734 | expect(typeof result.current.leave).toBe("function"); 735 | expect(result.current).toHaveProperty("leave"); 736 | expect(typeof result.current.leaveChannel).toBe("function"); 737 | expect(result.current).toHaveProperty("channel"); 738 | expect(result.current.channel).not.toBeNull(); 739 | expect(typeof result.current.channel().here).toBe("function"); 740 | expect(typeof result.current.channel().joining).toBe("function"); 741 | expect(typeof result.current.channel().leaving).toBe("function"); 742 | expect(typeof result.current.channel().whisper).toBe("function"); 743 | }); 744 | 745 | it("handles multiple events", async () => { 746 | const mockCallback = vi.fn(); 747 | const channelName = "test-channel"; 748 | const events = ["event1", "event2"]; 749 | 750 | const { result, unmount } = renderHook(() => 751 | echoModule.useEchoPresence(channelName, events, mockCallback), 752 | ); 753 | 754 | expect(result.current).toHaveProperty("leaveChannel"); 755 | 756 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 757 | 758 | const channel = echoInstance.join(channelName); 759 | 760 | expect(channel.listen).toHaveBeenCalledWith(events[0], mockCallback); 761 | expect(channel.listen).toHaveBeenCalledWith(events[1], mockCallback); 762 | 763 | expect(() => unmount()).not.toThrow(); 764 | 765 | expect(channel.stopListening).toHaveBeenCalledWith( 766 | events[0], 767 | mockCallback, 768 | ); 769 | expect(channel.stopListening).toHaveBeenCalledWith( 770 | events[1], 771 | mockCallback, 772 | ); 773 | }); 774 | 775 | it("cleans up subscriptions on unmount", async () => { 776 | const mockCallback = vi.fn(); 777 | const channelName = "test-channel"; 778 | const event = "test-event"; 779 | 780 | const { unmount } = renderHook(() => 781 | echoModule.useEchoPresence(channelName, event, mockCallback), 782 | ); 783 | 784 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 785 | 786 | expect(() => unmount()).not.toThrow(); 787 | 788 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 789 | `presence-${channelName}`, 790 | ); 791 | }); 792 | 793 | it("won't subscribe multiple times to the same channel", async () => { 794 | const mockCallback = vi.fn(); 795 | const channelName = "test-channel"; 796 | const event = "test-event"; 797 | 798 | const { unmount: unmount1 } = renderHook(() => 799 | echoModule.useEchoPresence(channelName, event, mockCallback), 800 | ); 801 | 802 | const { unmount: unmount2 } = renderHook(() => 803 | echoModule.useEchoPresence(channelName, event, mockCallback), 804 | ); 805 | 806 | expect(echoInstance.join).toHaveBeenCalledTimes(1); 807 | expect(echoInstance.join).toHaveBeenCalledWith(channelName); 808 | 809 | expect(() => unmount1()).not.toThrow(); 810 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 811 | 812 | expect(() => unmount2()).not.toThrow(); 813 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 814 | `presence-${channelName}`, 815 | ); 816 | }); 817 | 818 | it("can leave a channel", async () => { 819 | const mockCallback = vi.fn(); 820 | const channelName = "test-channel"; 821 | const event = "test-event"; 822 | 823 | const { result } = renderHook(() => 824 | echoModule.useEchoPresence(channelName, event, mockCallback), 825 | ); 826 | 827 | result.current.leaveChannel(); 828 | 829 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 830 | `presence-${channelName}`, 831 | ); 832 | }); 833 | 834 | it("can leave all channel variations", async () => { 835 | const mockCallback = vi.fn(); 836 | const channelName = "test-channel"; 837 | const event = "test-event"; 838 | 839 | const { result } = renderHook(() => 840 | echoModule.useEchoPresence(channelName, event, mockCallback), 841 | ); 842 | 843 | result.current.leave(); 844 | 845 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 846 | }); 847 | 848 | it("events and listeners are optional", async () => { 849 | const channelName = "test-channel"; 850 | 851 | const { result } = renderHook(() => 852 | echoModule.useEchoPresence(channelName), 853 | ); 854 | 855 | expect(result.current).toHaveProperty("channel"); 856 | expect(result.current.channel).not.toBeNull(); 857 | }); 858 | }); 859 | 860 | describe("useEchoNotification hook", async () => { 861 | let echoModule: typeof import("../src/hooks/use-echo"); 862 | let configModule: typeof import("../src/config/index"); 863 | let echoInstance: Echo<"null">; 864 | 865 | beforeEach(async () => { 866 | vi.resetModules(); 867 | 868 | echoInstance = new Echo({ 869 | broadcaster: "null", 870 | }); 871 | 872 | echoModule = await getEchoModule(); 873 | configModule = await getConfigModule(); 874 | 875 | configModule.configureEcho({ 876 | broadcaster: "null", 877 | }); 878 | }); 879 | 880 | afterEach(() => { 881 | vi.clearAllMocks(); 882 | }); 883 | 884 | it("subscribes to a private channel and listens for notifications", async () => { 885 | const mockCallback = vi.fn(); 886 | const channelName = "test-channel"; 887 | 888 | const { result } = renderHook(() => 889 | echoModule.useEchoNotification(channelName, mockCallback), 890 | ); 891 | 892 | expect(result.current).toHaveProperty("leaveChannel"); 893 | expect(typeof result.current.leave).toBe("function"); 894 | expect(result.current).toHaveProperty("leave"); 895 | expect(typeof result.current.leaveChannel).toBe("function"); 896 | expect(result.current).toHaveProperty("listen"); 897 | expect(typeof result.current.listen).toBe("function"); 898 | expect(result.current).toHaveProperty("stopListening"); 899 | expect(typeof result.current.stopListening).toBe("function"); 900 | }); 901 | 902 | it("sets up a notification listener on a channel", async () => { 903 | const mockCallback = vi.fn(); 904 | const channelName = "test-channel"; 905 | 906 | renderHook(() => 907 | echoModule.useEchoNotification(channelName, mockCallback), 908 | ); 909 | 910 | expect(echoInstance.private).toHaveBeenCalledWith(channelName); 911 | 912 | const channel = echoInstance.private(channelName); 913 | expect(channel.notification).toHaveBeenCalled(); 914 | }); 915 | 916 | it("handles notification filtering by event type", async () => { 917 | const mockCallback = vi.fn(); 918 | const channelName = "test-channel"; 919 | const eventType = "specific-type"; 920 | 921 | renderHook(() => 922 | echoModule.useEchoNotification( 923 | channelName, 924 | mockCallback, 925 | eventType, 926 | ), 927 | ); 928 | 929 | const channel = echoInstance.private(channelName); 930 | expect(channel.notification).toHaveBeenCalled(); 931 | 932 | const notificationCallback = vi.mocked(channel.notification).mock 933 | .calls[0][0]; 934 | 935 | const matchingNotification = { 936 | type: eventType, 937 | data: { message: "test" }, 938 | }; 939 | const nonMatchingNotification = { 940 | type: "other-type", 941 | data: { message: "test" }, 942 | }; 943 | 944 | notificationCallback(matchingNotification); 945 | notificationCallback(nonMatchingNotification); 946 | 947 | expect(mockCallback).toHaveBeenCalledWith(matchingNotification); 948 | expect(mockCallback).toHaveBeenCalledTimes(1); 949 | expect(mockCallback).not.toHaveBeenCalledWith(nonMatchingNotification); 950 | }); 951 | 952 | it("handles multiple notification event types", async () => { 953 | const mockCallback = vi.fn(); 954 | const channelName = "test-channel"; 955 | const events = ["type1", "type2"]; 956 | 957 | renderHook(() => 958 | echoModule.useEchoNotification(channelName, mockCallback, events), 959 | ); 960 | 961 | const channel = echoInstance.private(channelName); 962 | expect(channel.notification).toHaveBeenCalled(); 963 | 964 | const notificationCallback = vi.mocked(channel.notification).mock 965 | .calls[0][0]; 966 | 967 | const notification1 = { type: events[0], data: {} }; 968 | const notification2 = { type: events[1], data: {} }; 969 | const notification3 = { type: "type3", data: {} }; 970 | 971 | notificationCallback(notification1); 972 | notificationCallback(notification2); 973 | notificationCallback(notification3); 974 | 975 | expect(mockCallback).toHaveBeenCalledWith(notification1); 976 | expect(mockCallback).toHaveBeenCalledWith(notification2); 977 | expect(mockCallback).toHaveBeenCalledTimes(2); 978 | expect(mockCallback).not.toHaveBeenCalledWith(notification3); 979 | }); 980 | 981 | it("handles dotted and slashed notification event types", async () => { 982 | const mockCallback = vi.fn(); 983 | const channelName = "test-channel"; 984 | const events = [ 985 | "App.Notifications.First", 986 | "App\\Notifications\\Second", 987 | ]; 988 | 989 | renderHook(() => 990 | echoModule.useEchoNotification(channelName, mockCallback, events), 991 | ); 992 | 993 | const channel = echoInstance.private(channelName); 994 | expect(channel.notification).toHaveBeenCalled(); 995 | 996 | const notificationCallback = vi.mocked(channel.notification).mock 997 | .calls[0][0]; 998 | 999 | const notification1 = { type: "App\\Notifications\\First", data: {} }; 1000 | const notification2 = { type: "App\\Notifications\\Second", data: {} }; 1001 | 1002 | notificationCallback(notification1); 1003 | notificationCallback(notification2); 1004 | 1005 | expect(mockCallback).toHaveBeenCalledWith(notification1); 1006 | expect(mockCallback).toHaveBeenCalledWith(notification2); 1007 | expect(mockCallback).toHaveBeenCalledTimes(2); 1008 | }); 1009 | 1010 | it("accepts all notifications when no event types specified", async () => { 1011 | const mockCallback = vi.fn(); 1012 | const channelName = "test-channel"; 1013 | 1014 | renderHook(() => 1015 | echoModule.useEchoNotification(channelName, mockCallback), 1016 | ); 1017 | 1018 | const channel = echoInstance.private(channelName); 1019 | expect(channel.notification).toHaveBeenCalled(); 1020 | 1021 | const notificationCallback = vi.mocked(channel.notification).mock 1022 | .calls[0][0]; 1023 | 1024 | const notification1 = { type: "type1", data: {} }; 1025 | const notification2 = { type: "type2", data: {} }; 1026 | 1027 | notificationCallback(notification1); 1028 | notificationCallback(notification2); 1029 | 1030 | expect(mockCallback).toHaveBeenCalledWith(notification1); 1031 | expect(mockCallback).toHaveBeenCalledWith(notification2); 1032 | 1033 | expect(mockCallback).toHaveBeenCalledTimes(2); 1034 | }); 1035 | 1036 | it("cleans up subscriptions on unmount", async () => { 1037 | const mockCallback = vi.fn(); 1038 | const channelName = "test-channel"; 1039 | 1040 | const { unmount } = renderHook(() => 1041 | echoModule.useEchoNotification(channelName, mockCallback), 1042 | ); 1043 | 1044 | const channel = echoInstance.private(channelName); 1045 | 1046 | expect(() => unmount()).not.toThrow(); 1047 | 1048 | expect(channel.stopListeningForNotification).toHaveBeenCalled(); 1049 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 1050 | `private-${channelName}`, 1051 | ); 1052 | }); 1053 | 1054 | it("won't subscribe multiple times to the same channel", async () => { 1055 | const mockCallback = vi.fn(); 1056 | const channelName = "test-channel"; 1057 | 1058 | const { unmount: unmount1 } = renderHook(() => 1059 | echoModule.useEchoNotification(channelName, mockCallback), 1060 | ); 1061 | 1062 | const { unmount: unmount2 } = renderHook(() => 1063 | echoModule.useEchoNotification(channelName, mockCallback), 1064 | ); 1065 | 1066 | expect(echoInstance.private).toHaveBeenCalledTimes(1); 1067 | 1068 | expect(() => unmount1()).not.toThrow(); 1069 | expect(echoInstance.leaveChannel).not.toHaveBeenCalled(); 1070 | 1071 | expect(() => unmount2()).not.toThrow(); 1072 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 1073 | `private-${channelName}`, 1074 | ); 1075 | }); 1076 | 1077 | it("can leave a channel", async () => { 1078 | const mockCallback = vi.fn(); 1079 | const channelName = "test-channel"; 1080 | 1081 | const { result } = renderHook(() => 1082 | echoModule.useEchoNotification(channelName, mockCallback), 1083 | ); 1084 | 1085 | result.current.leaveChannel(); 1086 | 1087 | expect(echoInstance.leaveChannel).toHaveBeenCalledWith( 1088 | `private-${channelName}`, 1089 | ); 1090 | }); 1091 | 1092 | it("can leave all channel variations", async () => { 1093 | const mockCallback = vi.fn(); 1094 | const channelName = "test-channel"; 1095 | 1096 | const { result } = renderHook(() => 1097 | echoModule.useEchoNotification(channelName, mockCallback), 1098 | ); 1099 | 1100 | result.current.leave(); 1101 | 1102 | expect(echoInstance.leave).toHaveBeenCalledWith(channelName); 1103 | }); 1104 | 1105 | it("can manually start and stop listening", async () => { 1106 | const mockCallback = vi.fn(); 1107 | const channelName = "test-channel"; 1108 | 1109 | const { result } = renderHook(() => 1110 | echoModule.useEchoNotification(channelName, mockCallback), 1111 | ); 1112 | 1113 | const channel = echoInstance.private(channelName); 1114 | expect(channel.notification).toHaveBeenCalledTimes(1); 1115 | 1116 | result.current.stopListening(); 1117 | expect(channel.stopListeningForNotification).toHaveBeenCalled(); 1118 | 1119 | result.current.listen(); 1120 | 1121 | // notification should still only be called once due to initialized check 1122 | expect(channel.notification).toHaveBeenCalledTimes(1); 1123 | }); 1124 | 1125 | it("stopListening prevents new notification listeners", async () => { 1126 | const mockCallback = vi.fn(); 1127 | const channelName = "test-channel"; 1128 | 1129 | const { result } = renderHook(() => 1130 | echoModule.useEchoNotification(channelName, mockCallback), 1131 | ); 1132 | 1133 | result.current.stopListening(); 1134 | 1135 | expect(result.current.stopListening).toBeDefined(); 1136 | expect(typeof result.current.stopListening).toBe("function"); 1137 | }); 1138 | 1139 | it("callback and events are optional", async () => { 1140 | const channelName = "test-channel"; 1141 | 1142 | const { result } = renderHook(() => 1143 | echoModule.useEchoNotification(channelName), 1144 | ); 1145 | 1146 | expect(result.current).toHaveProperty("channel"); 1147 | expect(result.current.channel).not.toBeNull(); 1148 | }); 1149 | }); 1150 | --------------------------------------------------------------------------------