diff --git a/README.md b/README.md index e244912..5d3963f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Boundless Bounty is an open-source bounty platform built on Stellar that support - Node.js/Express - PostgreSQL - Redis (caching) +- **GraphQL Subscriptions** via `graphql-ws` (replaces Socket.IO for real-time sync) **Integrations:** diff --git a/docs/REALTIME_SYNC.md b/docs/REALTIME_SYNC.md new file mode 100644 index 0000000..f7597f5 --- /dev/null +++ b/docs/REALTIME_SYNC.md @@ -0,0 +1,59 @@ +# Real-Time Synchronization with GraphQL Subscriptions + +This document describes the real-time synchronization architecture that replaced the previous Socket.IO-based system. + +## Overview + +The application uses **GraphQL Subscriptions** via [graphql-ws](https://github.com/enisdenjo/graphql-ws) to receive real-time updates for bounty-related events. When an event occurs on the server (e.g., a bounty is created), a message is sent over a WebSocket connection to all subscribed clients, triggering automatic React Query cache invalidation. + +## Architecture + +1. **`lib/graphql/ws-client.ts`**: A singleton instance of the `graphql-ws` client. + - Handles WebSocket connection management. + - Injects authentication tokens via `connectionParams`. + - Manages automatic reconnection and retry logic. +2. **`lib/graphql/subscriptions.ts`**: Contains the typed GraphQL subscription documents and their response payload interfaces. +3. **`hooks/use-graphql-subscription.ts`**: A generic React hook that wraps the `wsClient.subscribe` method. + - Manages the subscription lifecycle (subscribes on mount, unsubscribes on unmount). + - Handles cleanup to prevent memory leaks. +4. **`hooks/use-bounty-subscription.ts`**: A high-level hook that aggregates subscriptions for `bountyCreated`, `bountyUpdated`, and `bountyDeleted`. + - Maps each event to specific React Query cache invalidation strategies using the global `bountyKeys` factory. + +## Cache Invalidation Strategy + +| Event | Action on Client | Cache Affected | +|-------|------------------|----------------| +| `bountyCreated` | `invalidateQueries` | `bountyKeys.lists()` | +| `bountyUpdated` | `invalidateQueries` | `bountyKeys.detail(id)`, `bountyKeys.lists()` | +| `bountyDeleted` | `removeQueries`, `invalidateQueries` | `bountyKeys.detail(id)`, `bountyKeys.lists()` | + +## Configuration + +The WebSocket endpoint is configured via an environment variable: + +```bash +NEXT_PUBLIC_GRAPHQL_WS_URL=ws://your-backend-url/graphql +``` + +Default value for local development: `ws://localhost:4000/graphql`. + +## Authentication + +Authentication is handled via the `Better Auth` session token. The `wsClient` automatically retrieves the latest access token and injects it into the WebSocket handshake through the `authorization` header within `connectionParams`. + +## Usage + +To enable real-time updates in a component or globally: + +```tsx +import { useBountySubscription } from '@/hooks/use-bounty-subscription'; + +function MyComponent() { + // Activate bounty synchronization + useBountySubscription(); + + return ( + // ... UI that React Query will automatically update + ); +} +``` diff --git a/hooks/__tests__/use-bounty-subscription.test.ts b/hooks/__tests__/use-bounty-subscription.test.ts new file mode 100644 index 0000000..c8b2bda --- /dev/null +++ b/hooks/__tests__/use-bounty-subscription.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from '@testing-library/react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useBountySubscription } from '../use-bounty-subscription'; +import { wsClient } from '@/lib/graphql/ws-client'; +import { + BOUNTY_CREATED_SUBSCRIPTION, + BOUNTY_UPDATED_SUBSCRIPTION, + BOUNTY_DELETED_SUBSCRIPTION, +} from '@/lib/graphql/subscriptions'; +import { bountyKeys } from '@/lib/query/query-keys'; + +// Mock dependencies +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: jest.fn(), +})); + +jest.mock('@/lib/graphql/ws-client', () => ({ + wsClient: { + subscribe: jest.fn(), + }, +})); + +// Mock query keys factory +jest.mock('@/lib/query/query-keys', () => ({ + bountyKeys: { + lists: jest.fn(() => ['Bounties', 'lists']), + detail: jest.fn((id: string) => ['Bounty', { id }]), + allListKeys: [ + ['Bounties', 'lists'], + ['ActiveBounties'], + ['OrganizationBounties'], + ['ProjectBounties'], + ], + }, +})); + +// Mock graphql-tag +jest.mock('graphql-tag', () => ({ + gql: (strings: string[]) => ({ + kind: 'Document', + definitions: [], + loc: { source: { body: strings[0].trim() } }, + }), +})); + +// Mock graphql print +jest.mock('graphql', () => ({ + print: jest.fn((query: { loc?: { source?: { body: string } } }) => query.loc?.source?.body ?? ''), +})); + +describe('useBountySubscription', () => { + let mockInvalidateQueries: jest.Mock; + let mockRemoveQueries: jest.Mock; + let mockSubscribe: jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockInvalidateQueries = jest.fn(); + mockRemoveQueries = jest.fn(); + (useQueryClient as jest.Mock).mockReturnValue({ + invalidateQueries: mockInvalidateQueries, + removeQueries: mockRemoveQueries, + }); + + mockSubscribe = wsClient.subscribe as jest.Mock; + mockSubscribe.mockReturnValue(mockUnsubscribe); + }); + + it('should subscribe to all three bounty events on mount', () => { + renderHook(() => useBountySubscription()); + + expect(mockSubscribe).toHaveBeenCalledTimes(3); + + // Check if it subscribes with the correct queries + const subscribedQueries = mockSubscribe.mock.calls.map(call => call[0].query); + expect(subscribedQueries).toContain((BOUNTY_CREATED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body); + expect(subscribedQueries).toContain((BOUNTY_UPDATED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body); + expect(subscribedQueries).toContain((BOUNTY_DELETED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body); + }); + + it('should cleanup subscriptions on unmount', () => { + const { unmount } = renderHook(() => useBountySubscription()); + unmount(); + expect(mockUnsubscribe).toHaveBeenCalledTimes(3); + }); + + it('should invalidate lists when bountyCreated arrives', () => { + renderHook(() => useBountySubscription()); + + // Find the call for bountyCreated and trigger its callback + const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyCreated')); + const callback = call[1].next; + + // Simulate incoming data + callback({ + data: { + bountyCreated: { + id: 'bounty-1', + title: 'New Bounty', + status: 'OPEN', + rewardAmount: 100, + rewardCurrency: 'XLM' + } + } + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); // Once for each entry in allListKeys + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['Bounties', 'lists'] + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['ActiveBounties'] + }); + }); + + it('should invalidate detail and lists when bountyUpdated arrives', () => { + renderHook(() => useBountySubscription()); + + // Find the call for bountyUpdated and trigger its callback + const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyUpdated')); + const callback = call[1].next; + + // Simulate incoming data + callback({ + data: { + bountyUpdated: { + id: 'bounty-1', + title: 'Updated Bounty', + status: 'IN_PROGRESS', + rewardAmount: 200, + rewardCurrency: 'XLM' + } + } + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: bountyKeys.detail('bounty-1') + }); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(5); // 4 for lists + 1 for detail + }); + + it('should remove detail and invalidate lists when bountyDeleted arrives', () => { + renderHook(() => useBountySubscription()); + + // Find the call for bountyDeleted and trigger its callback + const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyDeleted')); + const callback = call[1].next; + + // Simulate incoming data + callback({ + data: { + bountyDeleted: { id: 'bounty-1' } + } + }); + + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: bountyKeys.detail('bounty-1') + }); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); + }); +}); diff --git a/hooks/use-bounty-subscription.ts b/hooks/use-bounty-subscription.ts new file mode 100644 index 0000000..eb9910f --- /dev/null +++ b/hooks/use-bounty-subscription.ts @@ -0,0 +1,42 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useGraphQLSubscription } from './use-graphql-subscription'; +import { + BOUNTY_CREATED_SUBSCRIPTION, + BOUNTY_UPDATED_SUBSCRIPTION, + BOUNTY_DELETED_SUBSCRIPTION, + type BountyCreatedData, + type BountyUpdatedData, + type BountyDeletedData, +} from '@/lib/graphql/subscriptions'; +import { bountyKeys } from '@/lib/query/query-keys'; + +/** + * High-level hook that uses useGraphQLSubscription for all bounty-related events. + * It manages React Query cache invalidation when real-time updates are received. + */ +export function useBountySubscription() { + const queryClient = useQueryClient(); + + const invalidateAllLists = () => { + bountyKeys.allListKeys.forEach(queryKey => { + queryClient.invalidateQueries({ queryKey }); + }); + }; + + // Handle bountyCreated: invalidate all bounty list queries + useGraphQLSubscription(BOUNTY_CREATED_SUBSCRIPTION, {}, () => { + invalidateAllLists(); + }); + + // Handle bountyUpdated: invalidate the specific bounty detail query and all lists + useGraphQLSubscription(BOUNTY_UPDATED_SUBSCRIPTION, {}, (data) => { + queryClient.invalidateQueries({ queryKey: bountyKeys.detail(data.bountyUpdated.id) }); + invalidateAllLists(); + }); + + // Handle bountyDeleted: remove the specific detail and invalidate all lists + useGraphQLSubscription(BOUNTY_DELETED_SUBSCRIPTION, {}, (data) => { + queryClient.removeQueries({ queryKey: bountyKeys.detail(data.bountyDeleted.id) }); + invalidateAllLists(); + }); +} diff --git a/hooks/use-graphql-subscription.ts b/hooks/use-graphql-subscription.ts new file mode 100644 index 0000000..73582f7 --- /dev/null +++ b/hooks/use-graphql-subscription.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; +import { wsClient } from '@/lib/graphql/ws-client'; +import { type DocumentNode, print } from 'graphql'; + +/** + * Generic GraphQL Subscription Hook. + * Manages the lifecycle of a graphql-ws subscription, ensuring cleanup on unmount. + * + * @template T - The response shape of the subscription + * @param query - The subscription document (gql DocumentNode or string) + * @param variables - Subscription variables + * @param onData - Callback triggered on each data event + * @param onError - Optional error callback + */ +export function useGraphQLSubscription( + query: DocumentNode | string, + variables: Record, + onData: (data: T) => void, + onError?: (error: unknown) => void +) { + // Hold latest callbacks in refs so we don't restart the subscription when they change + const onDataRef = useRef(onData); + const onErrorRef = useRef(onError); + + useEffect(() => { + onDataRef.current = onData; + onErrorRef.current = onError; + }, [onData, onError]); + + // Track variables as a string to avoid reference-based flapping + const variablesString = JSON.stringify(variables); + + // Track query as a string + const queryString = typeof query === 'string' ? query : print(query); + + useEffect(() => { + const unsubscribe = wsClient.subscribe( + { query: queryString, variables: JSON.parse(variablesString) }, + { + next: ({ data }) => data && onDataRef.current(data), + error: (err) => { + console.error('[GraphQL Subscription] Error:', err); + onErrorRef.current?.(err); + }, + complete: () => { }, + } + ); + + return () => { + unsubscribe(); + }; + }, [queryString, variablesString]); +} diff --git a/hooks/use-socket-sync.ts b/hooks/use-socket-sync.ts deleted file mode 100644 index cd2272c..0000000 --- a/hooks/use-socket-sync.ts +++ /dev/null @@ -1,55 +0,0 @@ -'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/graphql/subscriptions.ts b/lib/graphql/subscriptions.ts new file mode 100644 index 0000000..525e1a6 --- /dev/null +++ b/lib/graphql/subscriptions.ts @@ -0,0 +1,68 @@ +import { gql } from 'graphql-tag'; + +/** + * GraphQL Subscription Documents for real-time bounty events. + */ + +export const BOUNTY_CREATED_SUBSCRIPTION = gql` + subscription BountyCreated { + bountyCreated { + id + title + status + rewardAmount + rewardCurrency + } + } +`; + +export const BOUNTY_UPDATED_SUBSCRIPTION = gql` + subscription BountyUpdated { + bountyUpdated { + id + title + status + rewardAmount + rewardCurrency + } + } +`; + +export const BOUNTY_DELETED_SUBSCRIPTION = gql` + subscription BountyDeleted { + bountyDeleted { + id + } + } +`; + +/** + * Type definitions for subscription response data. + * These ensure strict typing throughout the sync layer. + */ + +export interface BountyCreatedData { + bountyCreated: { + id: string; + title: string; + status: string; + rewardAmount: number; + rewardCurrency: string; + }; +} + +export interface BountyUpdatedData { + bountyUpdated: { + id: string; + title: string; + status: string; + rewardAmount: number; + rewardCurrency: string; + }; +} + +export interface BountyDeletedData { + bountyDeleted: { + id: string; + }; +} diff --git a/lib/graphql/ws-client.ts b/lib/graphql/ws-client.ts new file mode 100644 index 0000000..ccbf371 --- /dev/null +++ b/lib/graphql/ws-client.ts @@ -0,0 +1,19 @@ +import { createClient } from 'graphql-ws'; +import { getAccessToken } from '@/lib/auth-utils'; + +/** + * Configure the graphql-ws client for real-time subscriptions. + * It uses a singleton pattern to ensure only one connection is maintained. + */ +export const wsClient = createClient({ + url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL ?? 'ws://localhost:4000/graphql', + connectionParams: async () => { + const token = await getAccessToken(); + return { + authorization: token ? `Bearer ${token}` : undefined, + }; + }, + // Reconnection logic + retryAttempts: 5, + shouldRetry: () => true, +}); diff --git a/lib/query/query-keys.ts b/lib/query/query-keys.ts index a876639..6ac1909 100644 --- a/lib/query/query-keys.ts +++ b/lib/query/query-keys.ts @@ -47,6 +47,14 @@ export const bountyKeys = { useProjectBountiesQuery.getKey({ projectId, } as ProjectBountiesQueryVariables), + + // Aggregated keys for broad invalidation across different list types + allListKeys: [ + ["Bounties"], + ["ActiveBounties"], + ["OrganizationBounties"], + ["ProjectBounties"], + ], }; // Type helpers for query keys diff --git a/lib/query/sync/handlers.ts b/lib/query/sync/handlers.ts deleted file mode 100644 index d3b1424..0000000 --- a/lib/query/sync/handlers.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { type BountyFieldsFragment } from "@/lib/graphql/generated"; -import { type PaginatedResponse } from "@/lib/api/types"; -import { bountyKeys } from "@/hooks/use-bounties"; - -export function handleBountyCreated( - queryClient: QueryClient, - bounty: BountyFieldsFragment, -) { - 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: BountyFieldsFragment, -) { - 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 deleted file mode 100644 index 60f0fd9..0000000 --- a/lib/query/sync/socket-sync.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 4638b6e..0000000 --- a/lib/socket/client.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 a587adc..3e7e788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,8 @@ "embla-carousel-react": "^8.6.0", "graphql": "^16.12.0", "graphql-request": "^5.2.0", + "graphql-tag": "^2.12.6", + "graphql-ws": "^6.0.7", "input-otp": "^1.4.2", "lucide-react": "^0.562.0", "motion": "^12.29.2", @@ -62,7 +64,6 @@ "react-resizable-panels": "^4.4.1", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", - "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", @@ -187,6 +188,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1168,6 +1170,7 @@ "version": "1.4.18", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", "integrity": "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" @@ -1197,12 +1200,14 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@better-fetch/fetch": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==", + "peer": true }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -1316,6 +1321,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1339,6 +1345,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -7616,12 +7623,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "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", @@ -8042,6 +8043,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8076,6 +8078,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -8462,6 +8465,7 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -8471,6 +8475,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8481,6 +8486,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -8577,6 +8583,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -9263,6 +9270,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9961,6 +9969,7 @@ "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", "license": "MIT", + "peer": true, "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", @@ -10020,6 +10029,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11282,7 +11292,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -11326,49 +11337,6 @@ "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-client/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/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.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -11680,6 +11648,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11881,6 +11850,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12899,6 +12869,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -13237,7 +13208,6 @@ "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.1.0" @@ -13253,7 +13223,6 @@ "version": "6.0.7", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.7.tgz", "integrity": "sha512-yoLRW+KRlDmnnROdAu7sX77VNLC0bsFoZyGQJLy1cF+X/SkLg/fWkRGrEEYQK8o2cafJ2wmEaMqMEZB3U3DYDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -15421,6 +15390,7 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -15450,6 +15420,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -15583,6 +15554,7 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" } @@ -17369,6 +17341,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -17401,6 +17374,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -18623,6 +18597,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18653,6 +18628,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18665,6 +18641,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19634,34 +19611,6 @@ "tslib": "^2.0.3" } }, - "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", @@ -20247,7 +20196,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -20374,6 +20324,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -20513,6 +20464,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -20723,6 +20675,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21184,6 +21137,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -21277,6 +21231,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21290,6 +21245,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -21757,8 +21713,9 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -21792,14 +21749,6 @@ "dev": true, "license": "MIT" }, - "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -21948,6 +21897,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a8e7407..912e431 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "embla-carousel-react": "^8.6.0", "graphql": "^16.12.0", "graphql-request": "^5.2.0", + "graphql-tag": "^2.12.6", + "graphql-ws": "^6.0.7", "input-otp": "^1.4.2", "lucide-react": "^0.562.0", "motion": "^12.29.2", @@ -69,7 +71,6 @@ "react-resizable-panels": "^4.4.1", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", - "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", diff --git a/providers/query-provider.tsx b/providers/query-provider.tsx index b550b2f..46bc9a8 100644 --- a/providers/query-provider.tsx +++ b/providers/query-provider.tsx @@ -41,11 +41,19 @@ interface QueryProviderProps { children: ReactNode; } +import { useBountySubscription } from "@/hooks/use-bounty-subscription"; + +function RealtimeSync() { + useBountySubscription(); + return null; +} + export function QueryProvider({ children }: QueryProviderProps) { const [queryClient] = useState(getQueryClient); return ( + {children} {process.env.NODE_ENV === "development" && (