Skip to content

Commit 6f5aef0

Browse files
feat(websockets): add Redis adapter for horizontal scaling
Add Redis-based Socket.IO adapter to enable WebSocket communication across multiple API instances. This allows horizontal scaling by using Redis pub/sub for message broadcasting between servers. The adapter falls back to in-memory adapter when Redis is unavailable (e.g., development environments). Also added proper TypeScript types for third-party emote APIs in shared package for better type safety.
1 parent 3d048b4 commit 6f5aef0

7 files changed

Lines changed: 188 additions & 15 deletions

File tree

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
"@nestjs/swagger": "^11.4.2",
2323
"@nestjs/terminus": "^11.1.1",
2424
"@nestjs/websockets": "^11.1.19",
25+
"@socket.io/redis-adapter": "^8.3.0",
2526
"@twitch-chat-visualizer/shared": "workspace:*",
2627
"axios": "^1.15.2",
2728
"cache-manager": "^7.2.8",
2829
"cache-manager-redis-yet": "^5.1.5",
2930
"fastify": "^5.0.0",
31+
"ioredis": "^5.10.1",
3032
"nestjs-pino": "^4.6.1",
3133
"pino-http": "^11.0.0",
3234
"pino-pretty": "^13.1.3",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { IoAdapter } from '@nestjs/platform-socket.io';
2+
import { ServerOptions } from 'socket.io';
3+
import { createAdapter } from '@socket.io/redis-adapter';
4+
import { Redis } from 'ioredis';
5+
6+
export class RedisIoAdapter extends IoAdapter {
7+
private adapterConstructor: ReturnType<typeof createAdapter>;
8+
9+
async connectToRedis(): Promise<void> {
10+
const pubClient = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
11+
const subClient = pubClient.duplicate();
12+
13+
await Promise.all([pubClient.connect(), subClient.connect()]).catch((err) => {
14+
// Ignorando erros de conexão inicial para permitir fallback caso redis não exista no ambiente dev
15+
console.warn('⚠️ Redis for Socket.IO failed to connect. Falling back to memory adapter.', err.message);
16+
});
17+
18+
if (pubClient.status === 'ready' && subClient.status === 'ready') {
19+
this.adapterConstructor = createAdapter(pubClient, subClient);
20+
}
21+
}
22+
23+
createIOServer(port: number, options?: ServerOptions): any {
24+
const server = super.createIOServer(port, options);
25+
if (this.adapterConstructor) {
26+
server.adapter(this.adapterConstructor);
27+
}
28+
return server;
29+
}
30+
}

apps/api/src/cache/emote-cache.service.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,7 @@ import { TokenManagerService } from '../auth/token-manager.service';
55
import { ConfigService } from '@nestjs/config';
66
import axios from 'axios';
77
import qs from 'qs';
8-
9-
export interface ChannelAssets {
10-
globalBadges: any;
11-
channelBadges: any;
12-
bttvEmotes: any[];
13-
ffzEmotes: any[];
14-
}
8+
import { ChannelAssets, BttvEmote, FfzEmote } from '@twitch-chat-visualizer/shared';
159

1610
@Injectable()
1711
export class EmoteCacheService {
@@ -86,7 +80,7 @@ export class EmoteCacheService {
8680
const globalBadges = globalBadgesRes.status === 'fulfilled' ? globalBadgesRes.value.data : null;
8781
const channelBadges = channelBadgesRes.status === 'fulfilled' ? channelBadgesRes.value.data : null;
8882

89-
const bttvEmotes: any[] = [];
83+
const bttvEmotes: BttvEmote[] = [];
9084
if (bttvChannelRes.status === 'fulfilled') {
9185
bttvEmotes.push(...(bttvChannelRes.value.data.channelEmotes || []));
9286
bttvEmotes.push(...(bttvChannelRes.value.data.sharedEmotes || []));
@@ -95,7 +89,7 @@ export class EmoteCacheService {
9589
bttvEmotes.push(...(bttvGlobalRes.value.data || []));
9690
}
9791

98-
const ffzEmotes: any[] = [];
92+
const ffzEmotes: FfzEmote[] = [];
9993
if (ffzChannelRes.status === 'fulfilled') {
10094
const sets = ffzChannelRes.value.data.sets;
10195
const setKey = Object.keys(sets)[0];

apps/api/src/gateways/chat.gateway.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Server, Socket } from 'socket.io';
1111
import { Logger } from '@nestjs/common';
1212
import { TwitchService, TwitchMessagePayload } from '../twitch/twitch.service';
1313
import { EmoteCacheService } from '../cache/emote-cache.service';
14+
import { BttvEmote, FfzEmote, TwitchBadgesResponse } from '@twitch-chat-visualizer/shared';
1415

1516
@WebSocketGateway({
1617
cors: {
@@ -82,15 +83,15 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
8283
client.emit('redirect', link);
8384
}
8485

85-
private findBadgeUrl(badgesPayload: any, setId: string, versionId: string): string | null {
86+
private findBadgeUrl(badgesPayload: TwitchBadgesResponse | null | undefined, setId: string, versionId: string): string | null {
8687
if (!badgesPayload || !badgesPayload.data) return null;
87-
const set = badgesPayload.data.find((s: any) => s.set_id === setId);
88+
const set = badgesPayload.data.find((s) => s.set_id === setId);
8889
if (!set) return null;
89-
const version = set.versions.find((v: any) => v.id === versionId);
90+
const version = set.versions.find((v) => v.id === versionId);
9091
return version ? (version.image_url_4x || version.image_url_2x || version.image_url_1x) : null;
9192
}
9293

93-
private parseEmotes(message: string, emotesRaw: string | undefined, bttvEmotes: any[] = [], ffzEmotes: any[] = []): string {
94+
private parseEmotes(message: string, emotesRaw: string | undefined, bttvEmotes: BttvEmote[] = [], ffzEmotes: FfzEmote[] = []): string {
9495
if (!message) return '';
9596

9697
let messageWords = message.split(' ');
@@ -119,13 +120,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
119120
}
120121

121122
// Checa BTTV
122-
const bttvMatch = bttvEmotes.find((e: any) => e.code === word);
123+
const bttvMatch = bttvEmotes.find((e) => e.code === word);
123124
if (bttvMatch) {
124125
return `<img src="https://cdn.betterttv.net/emote/${bttvMatch.id}/1x" alt="${word}">`;
125126
}
126127

127128
// Checa FFZ
128-
const ffzMatch = ffzEmotes.find((e: any) => e.name === word);
129+
const ffzMatch = ffzEmotes.find((e) => e.name === word);
129130
if (ffzMatch) {
130131
return `<img src="https://cdn.frankerfacez.com/emote/${ffzMatch.id}/1" alt="${word}">`;
131132
}

apps/api/src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify
33
import { AppModule } from './app.module';
44
import { Logger } from 'nestjs-pino';
55
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
6+
import { RedisIoAdapter } from './adapters/redis-io.adapter';
67

78
async function bootstrap() {
89
const app = await NestFactory.create<NestFastifyApplication>(
@@ -13,6 +14,11 @@ async function bootstrap() {
1314

1415
app.useLogger(app.get(Logger));
1516

17+
// Configure Redis Socket.io Adapter for Horizontal Scaling (Stateless)
18+
const redisIoAdapter = new RedisIoAdapter(app);
19+
await redisIoAdapter.connectToRedis();
20+
app.useWebSocketAdapter(redisIoAdapter);
21+
1622
const port = process.env.PORT || 3000;
1723

1824
// Swagger Configuration

packages/shared/src/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,43 @@ export interface Emote {
1616
url: string;
1717
}
1818

19+
export interface BttvEmote {
20+
id: string;
21+
code: string;
22+
imageType: string;
23+
animated: boolean;
24+
}
25+
26+
export interface FfzEmote {
27+
id: number;
28+
name: string;
29+
height: number;
30+
width: number;
31+
}
32+
33+
export interface TwitchBadgeVersion {
34+
id: string;
35+
image_url_1x: string;
36+
image_url_2x: string;
37+
image_url_4x: string;
38+
}
39+
40+
export interface TwitchBadgeSet {
41+
set_id: string;
42+
versions: TwitchBadgeVersion[];
43+
}
44+
45+
export interface TwitchBadgesResponse {
46+
data: TwitchBadgeSet[];
47+
}
48+
49+
export interface ChannelAssets {
50+
globalBadges: TwitchBadgesResponse | null;
51+
channelBadges: TwitchBadgesResponse | null;
52+
bttvEmotes: BttvEmote[];
53+
ffzEmotes: FfzEmote[];
54+
}
55+
1956
export interface ChannelMessage {
2057
username: string;
2158
message: string;

0 commit comments

Comments
 (0)