From c29a51941b246ce23292df7d9e802894924e32b9 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Sun, 2 Feb 2025 13:00:48 +0100 Subject: [PATCH] Test visitors --- app/composables/visitors.ts | 177 ++++++++++++++++++++++++++++++++++++ nuxt.config.ts | 1 - package.json | 1 - pnpm-lock.yaml | 14 --- server/routes/visitors.ts | 21 +++++ 5 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 app/composables/visitors.ts create mode 100644 server/routes/visitors.ts diff --git a/app/composables/visitors.ts b/app/composables/visitors.ts new file mode 100644 index 0000000..124e03b --- /dev/null +++ b/app/composables/visitors.ts @@ -0,0 +1,177 @@ +import { useState } from '#imports' +import { onBeforeUnmount, onMounted, ref } from 'vue' + +/** + * Composable for tracking real-time website visitors count via WebSocket + * + * Features: + * - Real-time visitors count updates + * - Automatic WebSocket connection management + * - Connection status tracking + * - Error handling + * - Automatic cleanup on component unmount + * + * @returns {object} An object containing: + * - visitors: Ref - Current number of visitors + * - isLoading: Ref - Loading state indicator + * - error: Ref - Error message if any + * - isConnected: Ref - WebSocket connection status + * - reconnect: () => void - Function to manually reconnect + */ +export function useVisitors() { + // State management + const visitors = useState('visitors', () => 0) // Added default value + const locations = ref>([]) + const myLocation = useState('location', () => ({ + latitude: 0, + longitude: 0, + })) + const isLoading = ref(true) + const error = ref(null) + const wsRef = ref(null) + const isConnected = ref(false) + const isMounted = ref(true) + + // Constants + const RECONNECTION_DELAY = 5000 // 5 seconds delay for reconnection + const WS_NORMAL_CLOSURE = 1000 + + /** + * Constructs the WebSocket URL based on the current protocol and host + * @returns {string} WebSocket URL + */ + const getWebSocketUrl = (): string => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const baseUrl = window.location.host.replace(/^(http|https):\/\//, '') + return `${protocol}//${baseUrl}/.nuxt-visitors/ws?latitude=${myLocation.value.latitude}&longitude=${myLocation.value.longitude}` + } + + /** + * Cleans up WebSocket connection and resets state + */ + const cleanup = () => { + if (wsRef.value) { + wsRef.value.close() + wsRef.value = null + } + isConnected.value = false + isLoading.value = false + } + + /** + * Handles WebSocket messages + * @param {MessageEvent} event - WebSocket message event + */ + const handleMessage = async (event: MessageEvent) => { + if (!isMounted.value) + return + + try { + const data = typeof event.data === 'string' ? event.data : await event.data.text() + locations.value = JSON.parse(data) as { latitude: number, longitude: number }[] + const visitorCount = locations.value.length + if (!Number.isNaN(visitorCount) && visitorCount >= 0) { + visitors.value = visitorCount + } + else { + throw new Error('Invalid visitor count received') + } + } + catch (err) { + console.error('Failed to parse visitors WebSocket data:', err) + error.value = 'Invalid data received' + } + } + + /** + * Handles WebSocket connection closure + * @param {CloseEvent} event - WebSocket close event + */ + const handleClose = (event: CloseEvent) => { + console.log('Visitors WebSocket closed:', event.code, event.reason) + isConnected.value = false + wsRef.value = null + + if (isMounted.value && event.code !== WS_NORMAL_CLOSURE) { + error.value = 'Connection lost' + // Attempt to reconnect after delay + setTimeout(() => reconnect(), RECONNECTION_DELAY) + } + } + + /** + * Initializes WebSocket connection + */ + const initWebSocket = () => { + if (!isMounted.value) + return + + cleanup() + + try { + const ws = new WebSocket(getWebSocketUrl()) + wsRef.value = ws + + ws.onopen = () => { + if (!isMounted.value) { + ws.close() + return + } + console.log('Stats WebSocket connected') + isConnected.value = true + isLoading.value = false + error.value = null + } + + ws.onmessage = handleMessage + ws.onclose = handleClose + ws.onerror = (event: Event) => { + if (!isMounted.value) + return + console.error('Visitors WebSocket error:', event) + error.value = 'Connection error' + } + } + catch (err) { + if (!isMounted.value) + return + console.error('Failed to initialize Visitors WebSocket:', err) + error.value = 'Failed to initialize connection' + isLoading.value = false + } + } + + /** + * Manually triggers WebSocket reconnection + */ + const reconnect = () => { + if (!isMounted.value) + return + error.value = null + isLoading.value = true + initWebSocket() + } + + // Lifecycle hooks + onMounted(() => { + if (import.meta.client) { + isMounted.value = true + initWebSocket() + } + }) + + onBeforeUnmount(() => { + isMounted.value = false + cleanup() + }) + + return { + visitors, + locations, + myLocation, + isLoading, + error, + isConnected, + reconnect, + } +} diff --git a/nuxt.config.ts b/nuxt.config.ts index cf448ae..be9ab93 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -20,7 +20,6 @@ export default defineNuxtConfig({ '@nuxtjs/google-fonts', '@nuxt/image', '@nuxtjs/i18n', - 'nuxt-visitors', ], // Nuxt Hub diff --git a/package.json b/package.json index 1343ccf..cb6af4c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "drizzle-orm": "^0.33.0", "h3-zod": "^0.5.3", "nuxt": "^3.15.3", - "nuxt-visitors": "^1.1.2", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7735d1f..eac7fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: nuxt: specifier: ^3.15.3 version: 3.15.3(@libsql/client@0.5.6(encoding@0.1.13))(@parcel/watcher@2.4.1)(@types/node@22.12.0)(better-sqlite3@11.8.1)(db0@0.2.3(@libsql/client@0.5.6(encoding@0.1.13))(better-sqlite3@11.8.1)(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20250124.3)(@libsql/client@0.5.6(encoding@0.1.13))(@opentelemetry/api@1.9.0)(better-sqlite3@11.8.1)(pg@8.13.1)))(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20250124.3)(@libsql/client@0.5.6(encoding@0.1.13))(@opentelemetry/api@1.9.0)(better-sqlite3@11.8.1)(pg@8.13.1))(encoding@0.1.13)(eslint@9.19.0(jiti@2.4.2))(ioredis@5.4.1)(magicast@0.3.5)(meow@9.0.0)(optionator@0.9.4)(rollup@4.32.1)(sass@1.77.6)(terser@5.31.6)(typescript@5.7.3)(vite@6.0.11(@types/node@22.12.0)(jiti@2.4.2)(sass@1.77.6)(terser@5.31.6)(yaml@2.7.0))(vue-tsc@2.2.0(typescript@5.7.3))(yaml@2.7.0) - nuxt-visitors: - specifier: ^1.1.2 - version: 1.1.2(magicast@0.3.5)(rollup@4.32.1) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -4700,9 +4697,6 @@ packages: resolution: {integrity: sha512-iq7hbSnfp4Ff/PTMYBF8pYabTQuF3u7HVN66Kb3hOnrnaPEdXEn/q6HkAn5V8UjOVSgXYpvycM0wSnwyADYNVA==} hasBin: true - nuxt-visitors@1.1.2: - resolution: {integrity: sha512-gUSHTNhH0XDwo5/Op/+MrZe/inrLN++nuwLlMgwgciZmCQVrIbj8RXmtGAncVmeLKQim/n7gZlKWJbFASCq/rA==} - nuxt@3.15.3: resolution: {integrity: sha512-96D5vPMeqIxceIMvWms3a75Usi63zan/BGJvseXJqYGoi08fDBBql1lFWEa9rQb8QiRevfcmJQ9LiEj3jVjnkg==} engines: {node: ^18.20.5 || ^20.9.0 || >=22.0.0} @@ -12281,14 +12275,6 @@ snapshots: - rollup - supports-color - nuxt-visitors@1.1.2(magicast@0.3.5)(rollup@4.32.1): - dependencies: - '@nuxt/kit': 3.15.2(magicast@0.3.5)(rollup@4.32.1) - transitivePeerDependencies: - - magicast - - rollup - - supports-color - nuxt@3.15.3(@libsql/client@0.5.6(encoding@0.1.13))(@parcel/watcher@2.4.1)(@types/node@22.12.0)(better-sqlite3@11.8.1)(db0@0.2.3(@libsql/client@0.5.6(encoding@0.1.13))(better-sqlite3@11.8.1)(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20250124.3)(@libsql/client@0.5.6(encoding@0.1.13))(@opentelemetry/api@1.9.0)(better-sqlite3@11.8.1)(pg@8.13.1)))(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20250124.3)(@libsql/client@0.5.6(encoding@0.1.13))(@opentelemetry/api@1.9.0)(better-sqlite3@11.8.1)(pg@8.13.1))(encoding@0.1.13)(eslint@9.19.0(jiti@2.4.2))(ioredis@5.4.1)(magicast@0.3.5)(meow@9.0.0)(optionator@0.9.4)(rollup@4.32.1)(sass@1.77.6)(terser@5.31.6)(typescript@5.7.3)(vite@6.0.11(@types/node@22.12.0)(jiti@2.4.2)(sass@1.77.6)(terser@5.31.6)(yaml@2.7.0))(vue-tsc@2.2.0(typescript@5.7.3))(yaml@2.7.0): dependencies: '@nuxt/cli': 3.20.0(magicast@0.3.5) diff --git a/server/routes/visitors.ts b/server/routes/visitors.ts new file mode 100644 index 0000000..806d953 --- /dev/null +++ b/server/routes/visitors.ts @@ -0,0 +1,21 @@ +import { getQuery } from 'ufo' + +export default defineWebSocketHandler({ + open(peer) { + // We send the latitude and longitude query params when connecting to the server + const locations = Array.from(peer.peers.values()).map(peer => getQuery(peer.websocket.url!)) + // We send the (anonymous) user locations to the server + peer.subscribe('visitors') + peer.publish('visitors', JSON.stringify(locations)) + peer.send(JSON.stringify(locations)) + }, + close(peer) { + peer.unsubscribe('visitors') + // Wait 500ms before sending the updated locations to the server + // This to avoid sending the location of the user that just left + setTimeout(() => { + const locations = Array.from(peer.peers.values()).map(peer => getQuery(peer.websocket.url!)) + peer.publish('visitors', JSON.stringify(locations)) + }, 500) + }, +})