From bd4ffbf5ed3b66e292c22e533f0e4043c0c1f964 Mon Sep 17 00:00:00 2001 From: Tony Lea Date: Thu, 3 Apr 2025 17:03:04 -0400 Subject: [PATCH] Adding first iteration of the react and vue hook/composable functionality --- package.json | 12 ++- rollup.config.js | 40 +++++++- src/composables/useEcho.ts | 185 +++++++++++++++++++++++++++++++++++++ src/echo.ts | 3 + src/hooks/use-echo.ts | 169 +++++++++++++++++++++++++++++++++ 5 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 src/composables/useEcho.ts create mode 100644 src/hooks/use-echo.ts diff --git a/package.json b/package.json index 7a63b3a0..1a8f0ebb 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,17 @@ "require": "./dist/echo.common.js", "types": "./dist/echo.d.ts" }, - "./iife": "./dist/echo.iife.js" + "./iife": "./dist/echo.iife.js", + "./react": { + "import": "./dist/hooks/use-echo.js", + "require": "./dist/hooks/use-echo.js", + "types": "./dist/hooks/use-echo.d.ts" + }, + "./vue": { + "import": "./dist/composables/useEcho.js", + "require": "./dist/composables/useEcho.js", + "types": "./dist/composables/useEcho.d.ts" + } }, "overrides": { "glob": "^9.0.0" diff --git a/rollup.config.js b/rollup.config.js index d8e2b737..2752cf1d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -29,7 +29,7 @@ export default [ ], }), ], - external: ['jquery', 'axios', 'vue', '@hotwired/turbo', 'tslib'], // Compatible packages not included in the bundle + external: ['jquery', 'axios', 'vue', '@hotwired/turbo', 'tslib', 'react', 'pusher-js'], // Compatible packages not included in the bundle }, { input: './src/index.iife.ts', @@ -47,4 +47,42 @@ export default [ ], external: ['jquery', 'axios', 'vue', '@hotwired/turbo', 'tslib'], // Compatible packages not included in the bundle }, + { + input: './src/hooks/use-echo.ts', + output: [ + { file: './dist/hooks/use-echo.js', format: 'esm' }, + ], + plugins: [ + resolve(), + typescript({ + tsconfig: './tsconfig.json', + }), + babel({ + babelHelpers: 'bundled', + extensions: ['.ts'], + exclude: 'node_modules/**', + presets: ['@babel/preset-env'], + }), + ], + external: ['react', 'pusher-js', 'laravel-echo'], // React and other dependencies should be external + }, + { + input: './src/composables/useEcho.ts', + output: [ + { file: './dist/composables/useEcho.js', format: 'esm' }, + ], + plugins: [ + resolve(), + typescript({ + tsconfig: './tsconfig.json', + }), + babel({ + babelHelpers: 'bundled', + extensions: ['.ts'], + exclude: 'node_modules/**', + presets: ['@babel/preset-env'], + }), + ], + external: ['vue', 'pusher-js', 'laravel-echo'], // Vue and other dependencies should be external + }, ]; diff --git a/src/composables/useEcho.ts b/src/composables/useEcho.ts new file mode 100644 index 00000000..520db013 --- /dev/null +++ b/src/composables/useEcho.ts @@ -0,0 +1,185 @@ +import { ref, onMounted, onUnmounted, watch } from 'vue'; +import Echo, { EchoOptions } from 'laravel-echo'; +import Pusher from 'pusher-js'; + +// Define types for Echo channels +interface Channel { + listen(event: string, callback: (payload: any) => void): Channel; + stopListening(event: string, callback?: (payload: any) => void): Channel; +} + +interface EchoInstance extends Echo { + channel(channel: string): Channel; + private(channel: string): Channel; + leaveChannel(channel: string): void; +} + +interface ChannelData { + count: number; + channel: Channel; +} + +interface Channels { + [channelName: string]: ChannelData; +} + +// Create a singleton Echo instance +let echoInstance: EchoInstance | null = null; +let echoConfig: EchoOptions | null = null; + +// Configure Echo with custom options +export const configureEcho = (config: EchoOptions): void => { + echoConfig = config; + // Reset the instance if it was already created + if (echoInstance) { + echoInstance = null; + } +}; + +// Initialize Echo only once +const getEchoInstance = (): EchoInstance | null => { + if (!echoInstance) { + if (!echoConfig) { + console.error('Echo has not been configured. Please call configureEcho() with your configuration options before using Echo.'); + return null; + } + + // Temporarily add Pusher to window object for Echo initialization + // This is a compromise - we're still avoiding permanent global namespace pollution + // by only adding it temporarily during initialization + const originalPusher = (window as any).Pusher; + (window as any).Pusher = Pusher; + + // Configure Echo with provided config + echoInstance = new Echo(echoConfig) as EchoInstance; + + // Restore the original Pusher value to avoid side effects + if (originalPusher) { + (window as any).Pusher = originalPusher; + } else { + delete (window as any).Pusher; + } + } + return echoInstance; +}; + +// Keep track of all active channels +const channels: Channels = {}; + +// Export Echo instance for direct access if needed +export const echo = (): EchoInstance | null => getEchoInstance(); + +// Helper functions to interact with Echo +export const subscribeToChannel = (channelName: string, isPrivate = false): Channel | null => { + const instance = getEchoInstance(); + if (!instance) return null; + return isPrivate ? instance.private(channelName) : instance.channel(channelName); +}; + +export const leaveChannel = (channelName: string): void => { + const instance = getEchoInstance(); + if (!instance) return; + instance.leaveChannel(channelName); +}; + +// The main composable for using Echo in Vue components +export const useEcho = ( + channelName: string, + event: string | string[], + callback: (payload: any) => void, + dependencies: any[] = [], + visibility: 'private' | 'public' = 'private' +) => { + // Use ref to store the current callback + const eventCallback = ref(callback); + + // Track subscription for cleanup + let subscription: Channel | null = null; + let events: string[] = []; + let fullChannelName = ''; + + // Setup function to handle subscription + const setupSubscription = () => { + // Update callback ref + eventCallback.value = callback; + + // Format channel name based on visibility + fullChannelName = visibility === 'public' ? channelName : `${visibility}-${channelName}`; + const isPrivate = visibility === 'private'; + + // Reuse existing channel subscription or create a new one + if (!channels[fullChannelName]) { + const channel = subscribeToChannel(channelName, isPrivate); + if (!channel) return; + channels[fullChannelName] = { + count: 1, + channel, + }; + } else { + channels[fullChannelName].count += 1; + } + + subscription = channels[fullChannelName].channel; + + // Create listener function + const listener = (payload: any) => { + eventCallback.value(payload); + }; + + // Convert event to array if it's a single string + events = Array.isArray(event) ? event : [event]; + + // Subscribe to all events + events.forEach((e) => { + subscription?.listen(e, listener); + }); + }; + + // Cleanup function + const cleanup = () => { + if (subscription && events.length > 0) { + events.forEach((e) => { + subscription?.stopListening(e); + }); + + if (fullChannelName && channels[fullChannelName]) { + channels[fullChannelName].count -= 1; + if (channels[fullChannelName].count === 0) { + leaveChannel(fullChannelName); + delete channels[fullChannelName]; + } + } + } + }; + + // Setup subscription when component is mounted + onMounted(() => { + setupSubscription(); + }); + + // Clean up subscription when component is unmounted + onUnmounted(() => { + cleanup(); + }); + + // Watch dependencies and re-subscribe when they change + if (dependencies.length > 0) { + // Create a watch effect for each dependency + dependencies.forEach((dep, index) => { + watch(() => dependencies[index], () => { + // Clean up old subscription + cleanup(); + // Setup new subscription + setupSubscription(); + }, { deep: true }); + }); + } + + // Return the Echo instance for additional control if needed + return { + echo: getEchoInstance(), + leaveChannel: () => { + cleanup(); + } + }; +} \ No newline at end of file diff --git a/src/echo.ts b/src/echo.ts index c0729606..f933a51b 100644 --- a/src/echo.ts +++ b/src/echo.ts @@ -299,3 +299,6 @@ export type EchoOptions = { [key: string]: any; }; + +// Export React hook at react/use-echo.ts +// export * from './hooks/use-echo'; diff --git a/src/hooks/use-echo.ts b/src/hooks/use-echo.ts new file mode 100644 index 00000000..3e1b22a1 --- /dev/null +++ b/src/hooks/use-echo.ts @@ -0,0 +1,169 @@ +import { useEffect, useRef } from 'react'; +import Echo, { EchoOptions } from 'laravel-echo'; +import Pusher from 'pusher-js'; + +// Define types for Echo channels +interface Channel { + listen(event: string, callback: (payload: any) => void): Channel; + stopListening(event: string, callback?: (payload: any) => void): Channel; +} + +interface EchoInstance extends Echo { + channel(channel: string): Channel; + private(channel: string): Channel; + leaveChannel(channel: string): void; +} + +interface ChannelData { + count: number; + channel: Channel; +} + +interface Channels { + [channelName: string]: ChannelData; +} + +// Create a singleton Echo instance +let echoInstance: EchoInstance | null = null; +let echoConfig: EchoOptions | null = null; + +// Configure Echo with custom options +export const configureEcho = (config: EchoOptions): void => { + echoConfig = config; + // Reset the instance if it was already created + if (echoInstance) { + echoInstance = null; + } +}; + +// Initialize Echo only once +const getEchoInstance = (): EchoInstance | null => { + if (!echoInstance) { + if (!echoConfig) { + console.error('Echo has not been configured. Please call configureEcho() with your configuration options before using Echo.'); + return null; + } + + // Temporarily add Pusher to window object for Echo initialization + // This is a compromise - we're still avoiding permanent global namespace pollution + // by only adding it temporarily during initialization + const originalPusher = (window as any).Pusher; + (window as any).Pusher = Pusher; + + // Configure Echo with provided config + echoInstance = new Echo(echoConfig) as EchoInstance; + + // Restore the original Pusher value to avoid side effects + if (originalPusher) { + (window as any).Pusher = originalPusher; + } else { + delete (window as any).Pusher; + } + } + return echoInstance; +}; + +// Keep track of all active channels +const channels: Channels = {}; + +// Export Echo instance for direct access if needed +export const echo = (): EchoInstance | null => getEchoInstance(); + +// Helper functions to interact with Echo +export const subscribeToChannel = (channelName: string, isPrivate = false): Channel | null => { + const instance = getEchoInstance(); + if (!instance) return null; + return isPrivate ? instance.private(channelName) : instance.channel(channelName); +}; + +export const leaveChannel = (channelName: string): void => { + const instance = getEchoInstance(); + if (!instance) return; + instance.leaveChannel(channelName); +}; + +// Define the interface for useEcho hook parameters +interface UseEchoParams { + channel: string; + event: string | string[]; + callback: (payload: any) => void; + dependencies?: any[]; + visibility?: 'private' | 'public'; +} + +// The main hook for using Echo in React components +export const useEcho = (params: UseEchoParams) => { + const { + channel, + event, + callback, + dependencies = [], + visibility = 'private' + } = params; + + const eventRef = useRef(callback); + + useEffect(() => { + // Always use the latest callback + eventRef.current = callback; + + const channelName = visibility === 'public' ? channel : `${visibility}-${channel}`; + const isPrivate = visibility === 'private'; + + // Reuse existing channel subscription or create a new one + if (!channels[channelName]) { + const channelSubscription = subscribeToChannel(channel, isPrivate); + if (!channelSubscription) return; + + channels[channelName] = { + count: 1, + channel: channelSubscription, + }; + } else { + channels[channelName].count += 1; + } + + const subscription = channels[channelName].channel; + + const listener = (payload: any) => { + eventRef.current(payload); + }; + + const events = Array.isArray(event) ? event : [event]; + + // Subscribe to all events + events.forEach((e) => { + subscription.listen(e, listener); + }); + + // Cleanup function + return () => { + events.forEach((e) => { + subscription.stopListening(e, listener); + }); + + if (channels[channelName]) { + channels[channelName].count -= 1; + if (channels[channelName].count === 0) { + leaveChannel(channelName); + delete channels[channelName]; + } + } + }; + }, [...dependencies]); // eslint-disable-line + + // Return the Echo instance for additional control if needed + return { + echo: getEchoInstance(), + leaveChannel: () => { + const channelName = visibility === 'public' ? channel : `${visibility}-${channel}`; + if (channels[channelName]) { + channels[channelName].count -= 1; + if (channels[channelName].count === 0) { + leaveChannel(channelName); + delete channels[channelName]; + } + } + } + }; +}; \ No newline at end of file