Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions hooks/use-socket-sync.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
68 changes: 68 additions & 0 deletions lib/query/sync/handlers.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<Bounty>>(
{ 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<PaginatedResponse<Bounty>>(
{ 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<PaginatedResponse<Bounty>>(
{ 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) });
}
31 changes: 31 additions & 0 deletions lib/query/sync/socket-sync.ts
Original file line number Diff line number Diff line change
@@ -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');
};
}
64 changes: 64 additions & 0 deletions lib/socket/client.ts
Original file line number Diff line number Diff line change
@@ -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;
86 changes: 86 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading