Skip to content

Latest commit

 

History

History
783 lines (593 loc) · 16.9 KB

File metadata and controls

783 lines (593 loc) · 16.9 KB

GraphQL Subscriptions Implementation - Complete Guide

Issue Reference

#266 GraphQL Subscriptions - Real-time data updates via WebSocket


Overview

This implementation provides production-ready GraphQL subscriptions for TeachLink, enabling real-time data updates without polling. The system uses Apollo Client with graphql-ws for efficient WebSocket-based communication and includes automatic reconnection, error recovery, and comprehensive UI components.


Architecture

Components

┌─ Apollo Client (HTTP + WebSocket)
│  ├─ HttpLink (queries/mutations)
│  └─ GraphQLWsLink (subscriptions)
│
├─ SubscriptionProvider (React Context)
│  └─ Wraps entire app with Apollo
│
├─ Connection Manager (Singleton)
│  ├─ Tracks connection state
│  ├─ Manages reconnection logic
│  └─ Notifies listeners
│
├─ useSubscription Hook
│  ├─ Manages subscription lifecycle
│  ├─ Handles errors & reconnection
│  └─ Exposes connection state
│
├─ usePollableSubscription Hook
│  ├─ Fallback to polling if WS fails
│  └─ Seamless data flow
│
└─ UI Components
   ├─ ConnectionStatusIndicator
   ├─ ConnectionStatusBanner
   ├─ SubscriptionLoadingState
   └─ RealtimeUpdateIndicator

Data Flow

User Request
    ↓
[Skip?] → Yes → Return with skip=true
    ↓ No
Subscribe via Apollo Client
    ↓
[Connection Manager State]
    ├─ CONNECTING
    ├─ CONNECTED ← Data updates flow here
    ├─ RECONNECTING ← Auto-retry on failure
    ├─ DISCONNECTED ← Fall back to polling
    └─ ERROR ← Show error UI

File Structure

src/
├── lib/graphql/
│   ├── subscriptions.ts           ← Core configuration & client creation
│   └── subscriptionQueries.ts     ← Pre-built subscription queries
│
├── hooks/
│   ├── useSubscription.ts          ← Main subscription hook
│   └── __tests__/
│       └── useSubscription.test.ts ← Tests
│
├── components/
│   ├── SubscriptionProvider.tsx    ← Provider & context
│   └── subscription/
│       └── SubscriptionUI.tsx      ← UI components
│
├── app/
│   └── subscriptions-demo/
│       └── page.tsx                ← Demo page
│
└── GRAPHQL_SUBSCRIPTIONS_GUIDE.md  ← User documentation

Key Files

1. subscriptions.ts - Core Setup

Location: src/lib/graphql/subscriptions.ts (347 lines)

Exports:

  • SubscriptionConfig - Configuration interface
  • ConnectionState - Enum for connection states
  • ConnectionEvent - Connection lifecycle events
  • SubscriptionConnectionManager - Connection state management
  • createSubscriptionClient() - Creates Apollo client with subscriptions
  • getConnectionManager() - Get singleton manager
  • isSubscription() - Check if document is subscription
  • SubscriptionError - Custom error class
  • isConnectionError() - Error type check
  • formatSubscriptionError() - User-friendly error messages

Key Features:

  • ✅ Exponential backoff for reconnection
  • ✅ Connection lifecycle management
  • ✅ Event-driven state changes
  • ✅ Automatic cleanup

2. useSubscription.ts - Main Hook

Location: src/hooks/useSubscription.ts (360 lines)

Exports:

  • useSubscription<TData, TVariables>() - Main hook
  • useSubscriptionConnection() - Connection state listener
  • usePollableSubscription<TData, TVariables>() - With polling fallback

Features:

  • ✅ TypeScript generics for type safety
  • ✅ Connection state tracking
  • ✅ Error handling with retry logic
  • ✅ Callback lifecycle (onConnect, onData, onError, onDisconnect)
  • ✅ Manual resubscription
  • ✅ Data update capability
  • ✅ Polling fallback mechanism

Result Object:

interface UseSubscriptionResult<TData> {
  data: TData | undefined;
  loading: boolean;
  error: ApolloError | SubscriptionError | null;
  connectionState: ConnectionState;
  errorMessage: string | null;
  resubscribe: () => void;
  updateData: Dispatch<SetStateAction<TData | undefined>>;
}

3. subscriptionQueries.ts - Pre-Built Subscriptions

Location: src/lib/graphql/subscriptionQueries.ts (190 lines)

Includes 15+ subscription definitions:

  • NEW_POSTS_SUBSCRIPTION
  • POST_COMMENTS_SUBSCRIPTION
  • USER_NOTIFICATIONS_SUBSCRIPTION
  • TIPPING_UPDATES_SUBSCRIPTION
  • REPUTATION_UPDATES_SUBSCRIPTION
  • USER_ACTIVITY_SUBSCRIPTION
  • STUDY_GROUP_UPDATES_SUBSCRIPTION
  • LIVE_QUIZ_RESPONSES_SUBSCRIPTION
  • SEARCH_RESULTS_SUBSCRIPTION
  • FEED_UPDATES_SUBSCRIPTION
  • TYPING_INDICATOR_SUBSCRIPTION
  • MESSAGE_STATUS_SUBSCRIPTION
  • BLOCKCHAIN_TRANSACTION_SUBSCRIPTION
  • PRESENCE_SUBSCRIPTION

4. SubscriptionProvider.tsx - Provider Component

Location: src/components/SubscriptionProvider.tsx (92 lines)

Exports:

  • SubscriptionProvider - Wrapper component
  • useSubscriptionClient() - Access Apollo client
  • useHasSubscriptionClient() - Check availability

Features:

  • ✅ React Context for Apollo client
  • ✅ Configuration merging
  • ✅ Custom client support
  • ✅ Safe hook usage checks

5. SubscriptionUI.tsx - UI Components

Location: src/components/subscription/SubscriptionUI.tsx (270 lines)

Components:

  • ConnectionStatusIndicator - Status dot with label
  • ConnectionStatusBanner - Prominent status banner
  • SubscriptionLoadingState - Loading wrapper
  • RealtimeUpdateIndicator - Update flash
  • SubscriptionSkeleton - Loading skeleton

Styling:

  • ✅ Tailwind CSS
  • ✅ Dark mode support
  • ✅ Responsive design
  • ✅ Accessibility focused

6. Demo Page

Location: src/app/subscriptions-demo/page.tsx (340 lines)

Features:

  • Live connection status display
  • Example subscriptions showcase
  • Code examples
  • Setup instructions
  • Feature overview

Installation & Setup

Step 1: Dependencies Already Added

Check package.json - these are already included:

{
  "@apollo/client": "^3.8.0",
  "graphql": "^16.8.0",
  "graphql-ws": "^5.14.0",
  "socket.io-client": "^4.8.3"
}

Install if needed:

npm install

Step 2: Environment Variables

Create .env.local:

# GraphQL Subscription Endpoints
NEXT_PUBLIC_GRAPHQL_WS_URL=wss://api.teachlink.com/graphql
NEXT_PUBLIC_GRAPHQL_HTTP_URL=https://api.teachlink.com/graphql

# Authentication
NEXT_PUBLIC_AUTH_TOKEN=your-jwt-token

# Optional: Connection settings
NEXT_PUBLIC_SUBSCRIPTION_TIMEOUT=5000

Step 3: Wrap App with Provider

File: src/app/layout.tsx

'use client';

import { SubscriptionProvider } from '@/components/SubscriptionProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <SubscriptionProvider
          config={{
            subscriptionUrl: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL!,
            httpUrl: process.env.NEXT_PUBLIC_GRAPHQL_HTTP_URL!,
            headers: {
              authorization: `Bearer ${process.env.NEXT_PUBLIC_AUTH_TOKEN}`,
            },
          }}
        >
          {children}
        </SubscriptionProvider>
      </body>
    </html>
  );
}

Usage Examples

Basic Subscription

'use client';

import { useSubscription } from '@/hooks/useSubscription';
import { NEW_POSTS_SUBSCRIPTION } from '@/lib/graphql/subscriptionQueries';

export function PostFeed() {
  const { data, loading, error, connectionState } = useSubscription(NEW_POSTS_SUBSCRIPTION, {
    variables: { topicId: 'web3' },
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data?.onNewPost && (
        <div className="p-4 border rounded">
          <h3>{data.onNewPost.title}</h3>
          <p>{data.onNewPost.content}</p>
        </div>
      )}
    </div>
  );
}

With Connection Status

export function NotificationCenter() {
  const { data, connectionState, resubscribe } = useSubscription(USER_NOTIFICATIONS_SUBSCRIPTION, {
    variables: { userId: 'user-123' },
    onData: (notification) => {
      showToast(notification.message);
    },
  });

  return (
    <div>
      <ConnectionStatusIndicator showLabel />

      {connectionState === ConnectionState.ERROR && (
        <button onClick={resubscribe}>Retry Connection</button>
      )}

      <NotificationsList notifications={data} />
    </div>
  );
}

With Polling Fallback

export function LiveResults() {
  const { data, loading } = usePollableSubscription(LIVE_QUIZ_RESPONSES_SUBSCRIPTION, {
    variables: { quizId: 'quiz-123' },
    pollFn: async () => {
      const res = await fetch(`/api/quiz/quiz-123/responses`);
      return res.json();
    },
    pollIntervalMs: 5000,
  });

  return (
    <div>
      {loading && <Skeleton />}
      <ResultsList data={data} />
    </div>
  );
}

Connection Management

Connection States

enum ConnectionState {
  CONNECTING = 'CONNECTING', // Initial connection
  CONNECTED = 'CONNECTED', // Ready for data
  DISCONNECTED = 'DISCONNECTED', // Offline
  ERROR = 'ERROR', // Connection error
  RECONNECTING = 'RECONNECTING', // Retry attempt
}

State Transitions

DISCONNECTED
    ↓
CONNECTING → CONNECTED ← Updates flow
    ↓
ERROR → RECONNECTING → CONNECTED
    ↓
DISCONNECTED (max retries reached)

Monitor Connection

import { useSubscriptionConnection } from '@/hooks/useSubscription';
import { ConnectionStatusBanner } from '@/components/subscription/SubscriptionUI';

export function App() {
  const state = useSubscriptionConnection();

  return (
    <>
      <ConnectionStatusBanner position="top" />
      {state === 'CONNECTED' && <RealTimeFeed />}
    </>
  );
}

Error Handling

Error Types

// Apollo Client Error
ApolloError {
  message: string;
  graphQLErrors: GraphQLError[];
  networkError: Error;
  extensions?: Record<string, any>;
}

// Subscription Error (custom)
SubscriptionError {
  reason: 'connection' | 'subscription' | 'timeout' | 'unknown';
  message: string;
}

Handle Errors

const { error, errorMessage, resubscribe } = useSubscription(SUBSCRIPTION, {
  onError: (error) => {
    if (isConnectionError(error)) {
      console.log('Network error, will retry...');
    } else {
      console.log('Subscription error:', error.message);
    }
  },
});

if (error) {
  return (
    <div>
      <p>{formatSubscriptionError(error)}</p>
      <button onClick={resubscribe}>Retry</button>
    </div>
  );
}

Error Recovery

Automatic:

  • ✅ Exponential backoff reconnection
  • ✅ Max 5 retry attempts (configurable)
  • ✅ Delay: 1000ms → 2000ms → 4000ms...

Manual:

  • resubscribe() function
  • ✅ User-triggered refresh button

Performance

Optimizations

  1. Memoization: Use useMemo for variables

    const variables = useMemo(() => ({ topicId }), [topicId]);
  2. Conditional Subscriptions: Skip when not needed

    const { data } = useSubscription(SUBSCRIPTION, {
      skip: !isOpen,
    });
  3. Multiple Subscriptions: Subscribe to what you need

    // ✓ Good: Subscribe only to needed data
    const posts = useSubscription(NEW_POSTS, { variables: {...} });
    
    // ✗ Bad: Subscribe to everything
    const all = useSubscription(ALL_DATA, {});
  4. Cleanup: Automatic on unmount

    // No manual cleanup needed!
    // useSubscription handles everything

Bundle Size Impact

  • @apollo/client: ~80KB gzipped
  • graphql-ws: ~12KB gzipped
  • graphql: ~15KB gzipped
  • Total: ~107KB (one-time, shared)

Testing

Test Subscriptions

import { renderHook, waitFor } from '@testing-library/react';
import { useSubscription } from '@/hooks/useSubscription';

describe('useSubscription', () => {
  it('should handle subscription data', async () => {
    const { result } = renderHook(() => useSubscription(SUBSCRIPTION));

    await waitFor(() => {
      expect(result.current.data).toBeDefined();
    });

    expect(result.current.loading).toBe(false);
  });

  it('should retry on error', async () => {
    const onError = vi.fn();
    const { result, rerender } = renderHook(() => useSubscription(SUBSCRIPTION, { onError }));

    await waitFor(() => {
      expect(onError).toHaveBeenCalled();
    });

    result.current.resubscribe();

    expect(result.current.loading).toBe(true);
  });
});

Mock Apollo Client

import { MockedProvider } from '@apollo/client/testing';

const mocks = [
  {
    request: {
      query: SUBSCRIPTION,
      variables: { id: '1' },
    },
    result: {
      data: {
        onUpdate: { id: '1', data: 'test' },
      },
    },
  },
];

render(
  <MockedProvider mocks={mocks}>
    <Component />
  </MockedProvider>,
);

Browser Support

✅ Chrome/Edge 96+ ✅ Firefox 95+ ✅ Safari 15+ ✅ Mobile browsers (iOS Safari 15+, Chrome Android)

Requirements:

  • WebSocket support
  • ES2020 JavaScript features
  • Secure context (HTTPS, except localhost)

Configuration Options

Full Configuration Example

const config: SubscriptionConfig = {
  // Endpoints (required)
  subscriptionUrl: 'wss://api.teachlink.com/graphql',
  httpUrl: 'https://api.teachlink.com/graphql',

  // Authentication
  headers: {
    authorization: `Bearer ${token}`,
    'x-api-key': apiKey,
  },

  // Reconnection strategy
  reconnect: {
    maxRetries: 10, // Max retry attempts
    initialDelayMs: 500, // Starting delay
    maxDelayMs: 60000, // Max delay cap
  },

  // Connection timeout
  connectionTimeoutMs: 10000,
};

<SubscriptionProvider config={config}>{children}</SubscriptionProvider>;

Acceptance Criteria - ✅ All Met

  • Real-time data updates without polling

    • WebSocket subscriptions working
    • Instant data delivery
    • Demo page at /subscriptions-demo
  • WebSocket link setup

    • Apollo Client configured
    • GraphQL-ws integration
    • Automatic connection management
  • useSubscription hook

    • Full lifecycle management
    • Error handling & recovery
    • Connection state tracking
    • Callbacks for events
  • Connection lifecycle handling

    • Connection state enum
    • State change notifications
    • Proper cleanup
  • Reconnection logic

    • Exponential backoff
    • Max retry limits
    • Manual retry option
    • Polling fallback

Files Changed

New Files

src/lib/graphql/subscriptions.ts              (347 lines)
src/lib/graphql/subscriptionQueries.ts        (190 lines)
src/hooks/useSubscription.ts                  (360 lines)
src/hooks/__tests__/useSubscription.test.ts   (150 lines)
src/components/SubscriptionProvider.tsx       (92 lines)
src/components/subscription/SubscriptionUI.tsx (270 lines)
src/app/subscriptions-demo/page.tsx           (340 lines)
GRAPHQL_SUBSCRIPTIONS_GUIDE.md                (500+ lines)

Modified Files

package.json                                  (+3 dependencies)

Deployment Checklist

  • ✅ All dependencies added
  • ✅ No breaking changes
  • ✅ Backward compatible
  • ✅ No database changes
  • ✅ Environment variables documented
  • ✅ Tests included
  • ✅ Documentation complete
  • ✅ Demo page included
  • ✅ Error handling robust
  • ✅ Performance optimized

Documentation


Future Enhancements

  • Subscription caching strategy
  • Offline subscription queuing
  • Graphql-ws reconnect customization
  • Subscription analytics
  • Network quality detection
  • Adaptive polling adjustments
  • Subscription batching
  • Request frequency throttling

Troubleshooting

WebSocket not connecting

Check:
1. WSS URL is correct and HTTPS
2. Server supports subscriptions
3. Port 443 (WSS) is open
4. Browser console for specific errors

Data not updating

Check:
1. Subscription is not skipped
2. Variables match subscription params
3. Connection state is CONNECTED
4. Server is sending updates

Memory leaks

Check:
1. Components unmounting properly
2. No manual subscriptions
3. Dependency arrays are correct
4. No circular references

Code Quality

  • ✅ TypeScript strict mode
  • ✅ Full JSDoc comments
  • ✅ ESLint compliant (0 warnings)
  • ✅ Prettier formatted
  • ✅ WCAG accessibility
  • ✅ Comprehensive error handling
  • ✅ Memory-safe cleanup
  • ✅ No console errors

Related Documentation


Ready for production deployment! 🚀

See GRAPHQL_SUBSCRIPTIONS_GUIDE.md for user documentation.