Test visitors

This commit is contained in:
2025-02-02 13:00:48 +01:00
parent ab6f076b5d
commit c29a51941b
5 changed files with 198 additions and 16 deletions

177
app/composables/visitors.ts Normal file
View File

@@ -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<number> - Current number of visitors
* - isLoading: Ref<boolean> - Loading state indicator
* - error: Ref<string | null> - Error message if any
* - isConnected: Ref<boolean> - WebSocket connection status
* - reconnect: () => void - Function to manually reconnect
*/
export function useVisitors() {
// State management
const visitors = useState<number>('visitors', () => 0) // Added default value
const locations = ref<Array<{ latitude: number, longitude: number }>>([])
const myLocation = useState('location', () => ({
latitude: 0,
longitude: 0,
}))
const isLoading = ref(true)
const error = ref<string | null>(null)
const wsRef = ref<WebSocket | null>(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,
}
}

View File

@@ -20,7 +20,6 @@ export default defineNuxtConfig({
'@nuxtjs/google-fonts',
'@nuxt/image',
'@nuxtjs/i18n',
'nuxt-visitors',
],
// Nuxt Hub

View File

@@ -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",

14
pnpm-lock.yaml generated
View File

@@ -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)

21
server/routes/visitors.ts Normal file
View File

@@ -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)
},
})