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