Skip to main content

WebSocket Real-Time Updates Pattern

Template for implementing real-time features using WebSocket in this project.

Overview

This pattern was established with the Character Sync Progress feature and serves as a template for future real-time updates (guild roster sync, event notifications, etc.).

Architecture

flowchart TD
subgraph Frontend
hook["React Hook<br/>(useCharacterSync)"]
ws["WebSocket<br/>(useWebSocket)"]
end

subgraph Backend["NestJS Backend"]
gw["Gateway<br/>(CharacterSyncGateway)<br/>Room-based subscriptions"]
proc["Background Processor<br/>(CharacterEnrichmentProcessor)<br/>Bull queue worker"]
end

ws <-->|Socket.io| gw
proc -->|Emits events| gw

Backend Implementation

1. Create WebSocket Gateway

Location: apps/api/src/domains/[domain]/gateways/[feature].gateway.ts

@WebSocketGateway({
cors: {
origin: process.env.WEB_URL || 'http://localhost:5173',
credentials: true,
},
namespace: '/[feature-name]', // e.g., /character-sync
})
export class FeatureGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;

constructor(private readonly prisma: PrismaService) {}

handleConnection(client: Socket) {
console.log(`Client connected: ${client.id}`);
}

handleDisconnect(client: Socket) {
console.log(`Client disconnected: ${client.id}`);
}

@SubscribeMessage('subscribe-[feature]')
async handleSubscribe(
@MessageBody() data: { sessionId: string },
@ConnectedSocket() client: Socket
) {
// Join room for this session
client.join(data.sessionId);

// Send current state immediately
const state = await this.prisma.[model].findUnique({ where: { id: data.sessionId } });
this.emitProgress(data.sessionId, state);
}

@SubscribeMessage('unsubscribe-[feature]')
handleUnsubscribe(
@MessageBody() data: { sessionId: string },
@ConnectedSocket() client: Socket
) {
client.leave(data.sessionId);
}

// Method called by background processor
emitProgress(sessionId: string, progress: any) {
this.server.to(sessionId).emit('[feature]-progress', {
sessionId,
...progress,
});
}

emitComplete(sessionId: string, result: any) {
this.server.to(sessionId).emit('[feature]-complete', {
sessionId,
...result,
});
}

emitError(sessionId: string, error: string) {
this.server.to(sessionId).emit('[feature]-error', {
sessionId,
error,
});
}
}

2. Update Background Processor

Inject the gateway and emit events as work progresses:

@Processor('[queue-name]')
export class FeatureProcessor {
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => FeatureGateway))
private readonly gateway: FeatureGateway
) {}

@Process()
async handleJob(job: Job<JobData>): Promise<void> {
// Do work...

// Update session/state
const updated = await this.prisma.[model].update({
where: { id: sessionId },
data: { processedCount: current + 1 },
});

// Emit real-time update
this.gateway.emitProgress(sessionId, updated);

// On completion
if (isComplete) {
this.gateway.emitComplete(sessionId, finalData);
}
}
}

3. Register in Module

@Module({
imports: [
BullModule.registerQueue({ name: '[queue-name]' }),
forwardRef(() => OtherModule), // If needed for circular deps
],
providers: [
FeatureGateway,
FeatureProcessor,
// ...
],
exports: [FeatureGateway],
})
export class FeatureModule {}

Frontend Implementation

1. Use Generic WebSocket Hook

Already created: apps/web/src/hooks/useWebSocket.ts

const { isConnected, emit, on } = useWebSocket('/feature-name', {
autoConnect: false, // Connect manually when needed
onConnect: () => console.log('Connected'),
onDisconnect: () => console.log('Disconnected'),
onError: (err) => console.error('Error:', err),
});

2. Create Feature-Specific Hook

Location: apps/web/src/hooks/use[Feature]Progress.ts

import { useEffect, useState, useCallback } from 'react';
import { useWebSocket } from './useWebSocket';
import { useAppDispatch } from '../store/hooks';
import { featureApi } from '../store/api/featureApi';

export interface FeatureProgress {
sessionId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
// ...other fields
}

export function useFeatureProgress() {
const dispatch = useAppDispatch();
const [sessionId, setSessionId] = useState<string | null>(null);
const [progress, setProgress] = useState<FeatureProgress | null>(null);
const [error, setError] = useState<string | null>(null);

const { isConnected, emit, on } = useWebSocket('/feature-name', {
autoConnect: false,
onConnect: () => {
if (sessionId) {
emit('subscribe-[feature]', { sessionId });
}
},
});

useEffect(() => {
if (!isConnected || !sessionId) return;

emit('subscribe-[feature]', { sessionId });

const unsubProgress = on('[feature]-progress', (data: FeatureProgress) => {
setProgress(data);
});

const unsubComplete = on('[feature]-complete', (data: FeatureProgress) => {
setProgress(data);

// Refetch data via RTK Query
dispatch(
featureApi.util.invalidateTags([{ type: 'Feature', id: 'LIST' }])
);
});

const unsubError = on(
'[feature]-error',
(data: { sessionId: string; error: string }) => {
setError(data.error);
}
);

return () => {
emit('unsubscribe-[feature]', { sessionId });
unsubProgress();
unsubComplete();
unsubError();
};
}, [isConnected, sessionId, emit, on, dispatch]);

const startFeature = useCallback(
async (params: any) => {
setError(null);
setProgress(null);

const response = await dispatch(
featureApi.endpoints.startFeature.initiate(params)
).unwrap();

setSessionId(response.sessionId);
return response;
},
[dispatch]
);

return {
progress,
error,
isConnected,
isProcessing: progress?.status === 'processing',
isComplete: progress?.status === 'completed',
startFeature,
};
}

3. Create Progress Component

Location: apps/web/src/components/[Feature]Progress.tsx

import { Card, CardBody, Progress, Chip } from '@heroui/react';
import type { FeatureProgress } from '../hooks/use[Feature]Progress';

interface FeatureProgressProps {
progress: FeatureProgress;
}

export function FeatureProgress({ progress }: FeatureProgressProps) {
const statusColor = {
pending: 'default',
processing: 'primary',
completed: 'success',
failed: 'danger',
}[progress.status];

return (
<Card>
<CardBody className="gap-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Feature Progress</h3>
<Chip color={statusColor} size="sm" variant="flat">
{progress.status}
</Chip>
</div>

<Progress
value={progress.processed}
maxValue={progress.total}
color={statusColor}
showValueLabel={true}
/>

{progress.status === 'completed' && (
<div className="text-success">✓ Complete!</div>
)}
</CardBody>
</Card>
);
}

4. Use in Page Component

import { useFeatureProgress } from '../../hooks/use[Feature]Progress';
import { FeatureProgress } from '../../components/[Feature]Progress';

function FeaturePage() {
const { progress, startFeature } = useFeatureProgress();

const handleStart = async () => {
await startFeature({ /* params */ });
};

return (
<div>
{progress && <FeatureProgress progress={progress} />}

<button onClick={handleStart}>Start Feature</button>
</div>
);
}

Key Benefits

  1. No Polling - Server pushes updates instantly
  2. Room-Based - Multiple users can track different sessions simultaneously
  3. Reconnection Handling - Socket.io handles reconnects automatically
  4. Current State on Subscribe - New connections get immediate state
  5. Auto-Refetch - RTK Query invalidates cache on completion
  6. Reusable - Generic useWebSocket hook for all features
  7. Type-Safe - Full TypeScript support

Testing

Backend (Manual Test)

# Install wscat
npm install -g wscat

# Connect to gateway
wscat -c ws://localhost:3001/[feature-name]

# Subscribe
{"event": "subscribe-[feature]", "data": {"sessionId": "uuid-here"}}

# Listen for events
# Will receive: [feature]-progress, [feature]-complete, [feature]-error

Frontend

// In component
const { progress, startFeature } = useFeatureProgress();

useEffect(() => {
console.log('Progress update:', progress);
}, [progress]);

Implemented: Instance Sync Progress

Journal instance sync (Blizzard raids/dungeons) uses this pattern:

  • Backend Gateway: apps/api/src/game-data/gateways/instance-sync.gateway.ts
  • Backend Processor: apps/api/src/jobs/journal-instance-sync.processor.ts
  • Frontend Hook: apps/web/src/hooks/useInstanceSync.ts
  • Frontend Component: apps/web/src/components/InstanceSyncProgress.tsx
  • Usage: Admin panel (/admin) — trigger sync, subscribe to progress via WebSocket

Events: instance-sync-progress, instance-sync-complete, instance-sync-error

Future Features to Add

Using this pattern, we can add:

  • Guild Roster Sync Progress - Track roster sync with avatar fetching
  • Event Notifications - Real-time event updates for guild members
  • Loot Distribution - Live updates during loot distribution
  • Audit Log Streaming - Real-time audit log entries
  • Performance Monitoring - Live server/queue health metrics

Environment Variables

Backend (apps/api/.env.local):

# No additional vars needed, Socket.io uses same port as NestJS

Frontend (apps/web/.env.local):

VITE_API_URL=http://localhost:3001

File Structure

apps/
├── api/
│ └── src/
│ ├── common/
│ │ └── external-api/ # External API adapters
│ ├── domains/
│ │ └── [domain]/
│ │ ├── gateways/
│ │ │ ├── [feature].gateway.ts
│ │ │ └── README.md
│ │ └── ...
│ └── jobs/
│ └── [feature]-processor.ts
└── web/
└── src/
├── hooks/
│ ├── useWebSocket.ts # Generic WebSocket hook
│ └── use[Feature].ts # Feature-specific hook
└── components/
└── [Feature]Progress.tsx # Progress UI component

Complete Examples

Character Sync (original):

  • Backend Gateway: apps/api/src/domains/character/gateways/character-sync.gateway.ts
  • Backend Processor: apps/api/src/jobs/character-enrichment.processor.ts
  • Frontend Hook: apps/web/src/hooks/useCharacterSync.ts
  • Frontend Component: apps/web/src/components/CharacterSyncProgress.tsx
  • Frontend Usage: apps/web/src/routes/characters/index.tsx

Instance Sync (journal raids/dungeons):

  • Backend Gateway: apps/api/src/game-data/gateways/instance-sync.gateway.ts
  • Backend Processor: apps/api/src/jobs/journal-instance-sync.processor.ts
  • Frontend Hook: apps/web/src/hooks/useInstanceSync.ts
  • Frontend Component: apps/web/src/components/InstanceSyncProgress.tsx
  • Frontend Usage: apps/web/src/routes/admin.tsx

This serves as the canonical reference for implementing future real-time features.