diff --git a/hooks/use-socket-sync.ts b/hooks/use-socket-sync.ts new file mode 100644 index 00000000..cd2272cb --- /dev/null +++ b/hooks/use-socket-sync.ts @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { socket } from '@/lib/socket/client'; +import { setupSocketSync } from '@/lib/query/sync/socket-sync'; +import { authClient } from '@/lib/auth-client'; + +export function useSocketSync() { + const queryClient = useQueryClient(); + const { data: session, isPending: isSessionLoading } = authClient.useSession(); + const [isConnected, setIsConnected] = useState(socket.connected); + + useEffect(() => { + const onConnect = () => setIsConnected(true); + const onDisconnect = () => setIsConnected(false); + + socket.on('connect', onConnect); + socket.on('disconnect', onDisconnect); + + return () => { + socket.off('connect', onConnect); + socket.off('disconnect', onDisconnect); + }; + }, []); + + useEffect(() => { + if (isSessionLoading) return; + + // Connect socket if authenticated + if (session) { + console.log('[Socket] Connecting...'); + socket.connect(); + + // Setup synchronization listeners + const cleanupSync = setupSocketSync(queryClient); + + return () => { + console.log('[Socket] Disconnecting...'); + cleanupSync(); + socket.disconnect(); + }; + } else { + // Ensure socket is disconnected if session is lost + if (socket.connected) { + socket.disconnect(); + } + } + }, [session, isSessionLoading, queryClient]); + + return { + socket, + isConnected, + }; +} diff --git a/lib/query/sync/handlers.ts b/lib/query/sync/handlers.ts new file mode 100644 index 00000000..d1c98a0f --- /dev/null +++ b/lib/query/sync/handlers.ts @@ -0,0 +1,68 @@ +import { QueryClient } from '@tanstack/react-query'; +import { Bounty, PaginatedResponse } from '@/lib/api'; +import { bountyKeys } from '@/hooks/use-bounties'; + +export function handleBountyCreated(queryClient: QueryClient, bounty: Bounty) { + console.log('[Sync] Handling bounty.created:', bounty.id); + + // Update lists + queryClient.setQueriesData>( + { queryKey: bountyKeys.lists() }, + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + data: [bounty, ...oldData.data], + pagination: { + ...oldData.pagination, + total: oldData.pagination.total + 1, + }, + }; + } + ); + + // Set detail cache + queryClient.setQueryData(bountyKeys.detail(bounty.id), bounty); +} + +export function handleBountyUpdated(queryClient: QueryClient, bounty: Bounty) { + console.log('[Sync] Handling bounty.updated:', bounty.id); + + // Update lists + queryClient.setQueriesData>( + { queryKey: bountyKeys.lists() }, + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + data: oldData.data.map((b) => (b.id === bounty.id ? bounty : b)), + }; + } + ); + + // Update detail cache + queryClient.setQueryData(bountyKeys.detail(bounty.id), bounty); +} + +export function handleBountyDeleted(queryClient: QueryClient, bountyId: string) { + console.log('[Sync] Handling bounty.deleted:', bountyId); + + // Update lists + queryClient.setQueriesData>( + { queryKey: bountyKeys.lists() }, + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + data: oldData.data.filter((b) => b.id !== bountyId), + pagination: { + ...oldData.pagination, + total: Math.max(0, oldData.pagination.total - 1), + }, + }; + } + ); + + // Invalidate or remove detail cache + queryClient.removeQueries({ queryKey: bountyKeys.detail(bountyId) }); +} diff --git a/lib/query/sync/socket-sync.ts b/lib/query/sync/socket-sync.ts new file mode 100644 index 00000000..60f0fd9e --- /dev/null +++ b/lib/query/sync/socket-sync.ts @@ -0,0 +1,31 @@ +import { QueryClient } from '@tanstack/react-query'; +import { socket } from '@/lib/socket/client'; +import * as handlers from './handlers'; + +export function setupSocketSync(queryClient: QueryClient) { + console.log('[Sync] Setting up socket listeners...'); + + // Bounties + socket.on('bounty.created', (payload) => { + handlers.handleBountyCreated(queryClient, payload); + }); + + socket.on('bounty.updated', (payload) => { + handlers.handleBountyUpdated(queryClient, payload); + }); + + socket.on('bounty.deleted', (payload) => { + // payload might be the ID string or an object with id + const bountyId = typeof payload === 'string' ? payload : payload.id; + handlers.handleBountyDeleted(queryClient, bountyId); + }); + + // Add more entity listeners here as needed + + return () => { + console.log('[Sync] Removing socket listeners...'); + socket.off('bounty.created'); + socket.off('bounty.updated'); + socket.off('bounty.deleted'); + }; +} diff --git a/lib/socket/client.ts b/lib/socket/client.ts new file mode 100644 index 00000000..4638b6eb --- /dev/null +++ b/lib/socket/client.ts @@ -0,0 +1,64 @@ +import { io, Socket } from 'socket.io-client'; + +const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + +export const socket: Socket = io(SOCKET_URL, { + autoConnect: false, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 20000, + withCredentials: true, // Useful if the server uses cookies for auth + transports: ['websocket'], // Prefer WebSockets for better performance +}); + +let lastPingTime: number; + +// Logging and Event Handlers +socket.on('connect', () => { + console.log('[Socket] Connected to server:', socket.id); + + // Observe Engine.IO packets for heartbeat/latency monitoring (Socket.IO v4+) + const engine = socket.io.engine; + + engine.on('packet', (packet) => { + if (packet.type === 'ping') { + lastPingTime = Date.now(); + console.debug('[Socket] Heartbeat ping (sent)'); + } else if (packet.type === 'pong') { + const latency = Date.now() - lastPingTime; + console.debug('[Socket] Heartbeat pong (received), latency:', latency, 'ms'); + } + }); +}); + +socket.on('disconnect', (reason) => { + console.log('[Socket] Disconnected:', reason); + if (reason === 'io server disconnect') { + // The disconnection was initiated by the server, you need to reconnect manually + socket.connect(); + } +}); + +socket.on('connect_error', (error) => { + console.error('[Socket] Connection error:', error); +}); + +socket.on('reconnect', (attemptNumber) => { + console.log('[Socket] Reconnected after', attemptNumber, 'attempts'); +}); + +socket.on('reconnect_attempt', (attemptNumber) => { + console.log('[Socket] Reconnection attempt:', attemptNumber); +}); + +socket.on('reconnect_error', (error) => { + console.error('[Socket] Reconnection error:', error); +}); + +socket.on('reconnect_failed', () => { + console.error('[Socket] Reconnection failed'); +}); + +export default socket; diff --git a/package-lock.json b/package-lock.json index e8bd3b9a..0e893b55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.4.1", "recharts": "^2.15.4", + "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", @@ -3022,6 +3023,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5310,6 +5317,28 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -9492,6 +9521,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10487,6 +10544,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 38ec8ab5..1056c8a4 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.4.1", "recharts": "^2.15.4", + "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2",