Skip to content

Commit 2bc7799

Browse files
feat: implement NestJS API gateway with chat and assets services
- Add Twitch service to connect to IRC and relay chat events via WebSocket - Introduce token manager for Twitch API authentication with scheduled refresh - Implement emote cache service to fetch and store channel assets (badges, emotes) - Create WebSocket gateway for real-time chat communication with rooms - Add assets controller to expose cached channel assets via REST API - Configure Redis caching with fallback to in-memory storage - Set up proxy to legacy Express app for Strangler Fig migration pattern - Update package dependencies and add Docker commands for Redis service
1 parent 2655252 commit 2bc7799

17 files changed

Lines changed: 1721 additions & 24 deletions

apps/api/package.json

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,40 @@
66
"dev": "nest start --watch",
77
"build": "nest build",
88
"start": "node dist/main.js",
9-
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
9+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
10+
"test": "vitest run",
11+
"test:watch": "vitest"
1012
},
1113
"dependencies": {
14+
"@fastify/cors": "^11.2.0",
15+
"@fastify/http-proxy": "^11.4.4",
16+
"@nestjs/cache-manager": "^3.1.2",
1217
"@nestjs/common": "^11.0.0",
18+
"@nestjs/config": "^4.0.4",
1319
"@nestjs/core": "^11.0.0",
1420
"@nestjs/platform-fastify": "^11.0.0",
21+
"@nestjs/platform-socket.io": "^11.1.19",
22+
"@nestjs/schedule": "^6.1.3",
23+
"@nestjs/websockets": "^11.1.19",
24+
"@twitch-chat-visualizer/shared": "workspace:*",
25+
"axios": "^0.21.4",
26+
"cache-manager": "^7.2.8",
27+
"cache-manager-redis-yet": "^5.1.5",
1528
"fastify": "^5.0.0",
1629
"reflect-metadata": "^0.2.0",
1730
"rxjs": "^7.8.1",
18-
"@twitch-chat-visualizer/shared": "workspace:*"
31+
"socket.io": "^3.1.2",
32+
"tmi.js": "^1.8.5"
1933
},
2034
"devDependencies": {
2135
"@nestjs/cli": "^11.0.0",
36+
"@twitch-chat-visualizer/config-ts": "workspace:*",
2237
"@types/node": "^22.0.0",
38+
"@types/tmi.js": "^1.8.6",
39+
"@vitest/ui": "^4.1.5",
40+
"socket.io-client": "^4.8.3",
2341
"typescript": "^5.4.0",
24-
"@twitch-chat-visualizer/config-ts": "workspace:*"
42+
"unplugin-swc": "^1.5.9",
43+
"vitest": "^4.1.5"
2544
}
2645
}

apps/api/src/app.module.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,45 @@
11
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { ScheduleModule } from '@nestjs/schedule';
4+
import { CacheModule } from '@nestjs/cache-manager';
5+
import { redisStore } from 'cache-manager-redis-yet';
26
import { AppController } from './app.controller';
7+
import { AssetsController } from './controllers/assets.controller';
8+
import { AuthModule } from './auth/auth.module';
9+
import { CacheExtModule } from './cache/cache.module';
10+
import { TwitchModule } from './twitch/twitch.module';
11+
import { GatewaysModule } from './gateways/gateways.module';
312

413
@Module({
5-
imports: [],
6-
controllers: [AppController],
14+
imports: [
15+
ConfigModule.forRoot({
16+
isGlobal: true,
17+
envFilePath: '../../.env', // Pode ler da raiz do monorepo se desejar
18+
}),
19+
ScheduleModule.forRoot(),
20+
CacheModule.registerAsync({
21+
isGlobal: true,
22+
useFactory: async () => {
23+
try {
24+
const store = await redisStore({
25+
url: process.env.REDIS_URL || 'redis://localhost:6379',
26+
socket: {
27+
reconnectStrategy: () => new Error('Redis not available'),
28+
}
29+
});
30+
return { store };
31+
} catch (error) {
32+
console.warn('⚠️ Redis connection failed, falling back to in-memory cache.');
33+
return {}; // Retornar um objeto vazio instrui o cache-manager a usar o cache em memória padrão do Node
34+
}
35+
},
36+
}),
37+
AuthModule,
38+
CacheExtModule,
39+
TwitchModule,
40+
GatewaysModule,
41+
],
42+
controllers: [AppController, AssetsController],
743
providers: [],
844
})
945
export class AppModule {}

apps/api/src/auth/auth.module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { TokenManagerService } from './token-manager.service';
3+
4+
@Module({
5+
providers: [TokenManagerService],
6+
exports: [TokenManagerService],
7+
})
8+
export class AuthModule {}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { TokenManagerService } from './token-manager.service';
3+
import { ConfigService } from '@nestjs/config';
4+
5+
describe('TokenManagerService', () => {
6+
let tokenManagerService: TokenManagerService;
7+
let mockCacheManager: any;
8+
let mockConfigService: any;
9+
10+
beforeEach(() => {
11+
mockCacheManager = {
12+
get: vi.fn(),
13+
set: vi.fn(),
14+
};
15+
16+
mockConfigService = {
17+
get: vi.fn().mockImplementation((key: string) => {
18+
if (key === 'TWITCH_CLIENT_ID') return 'mock-client-id';
19+
if (key === 'TWITCH_CLIENT_SECRET') return 'mock-client-secret';
20+
return null;
21+
}),
22+
};
23+
24+
tokenManagerService = new TokenManagerService(
25+
mockConfigService as unknown as ConfigService,
26+
mockCacheManager
27+
);
28+
});
29+
30+
it('should return token from cache if available', async () => {
31+
mockCacheManager.get.mockResolvedValue('cached-token');
32+
33+
const token = await tokenManagerService.getToken();
34+
35+
expect(mockCacheManager.get).toHaveBeenCalledWith('TWITCH_APP_ACCESS_TOKEN');
36+
expect(token).toBe('cached-token');
37+
});
38+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { Cron, CronExpression } from '@nestjs/schedule';
4+
import { Inject } from '@nestjs/common';
5+
import { CACHE_MANAGER } from '@nestjs/cache-manager';
6+
import { Cache } from 'cache-manager';
7+
import axios from 'axios';
8+
import qs from 'qs';
9+
10+
@Injectable()
11+
export class TokenManagerService implements OnModuleInit {
12+
private readonly logger = new Logger(TokenManagerService.name);
13+
private readonly TOKEN_KEY = 'TWITCH_APP_ACCESS_TOKEN';
14+
15+
constructor(
16+
private readonly configService: ConfigService,
17+
@Inject(CACHE_MANAGER) private cacheManager: Cache,
18+
) {}
19+
20+
async onModuleInit() {
21+
await this.ensureTokenValid();
22+
}
23+
24+
async getToken(): Promise<string> {
25+
let token = await this.cacheManager.get<string>(this.TOKEN_KEY);
26+
if (!token) {
27+
token = await this.refreshToken();
28+
}
29+
return token;
30+
}
31+
32+
// Executa a cada 30 minutos para garantir que o token esteja sempre válido (eles duram ~60 dias, mas é bom previnir)
33+
@Cron(CronExpression.EVERY_30_MINUTES)
34+
async ensureTokenValid() {
35+
const token = await this.cacheManager.get<string>(this.TOKEN_KEY);
36+
if (!token) {
37+
await this.refreshToken();
38+
}
39+
}
40+
41+
private async refreshToken(): Promise<string> {
42+
this.logger.log('Fetching new Twitch App Access Token...');
43+
44+
const clientId = this.configService.get<string>('TWITCH_CLIENT_ID');
45+
const clientSecret = this.configService.get<string>('TWITCH_CLIENT_SECRET');
46+
47+
if (!clientId || !clientSecret) {
48+
this.logger.error('Missing TWITCH_CLIENT_ID or TWITCH_CLIENT_SECRET in .env');
49+
return '';
50+
}
51+
52+
try {
53+
const response = await axios.post(
54+
'https://id.twitch.tv/oauth2/token',
55+
qs.stringify({
56+
client_id: clientId,
57+
client_secret: clientSecret,
58+
grant_type: 'client_credentials',
59+
}),
60+
{
61+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
62+
},
63+
);
64+
65+
const { access_token, expires_in } = response.data;
66+
67+
// TTL = expiração menos 5 minutos de margem de segurança (em ms)
68+
const ttl = (expires_in - 300) * 1000;
69+
70+
await this.cacheManager.set(this.TOKEN_KEY, access_token, ttl);
71+
72+
this.logger.log(`New token fetched. Expires in ${expires_in} seconds.`);
73+
return access_token;
74+
} catch (error: any) {
75+
this.logger.error(`Failed to fetch Twitch token: ${error.message}`);
76+
throw error;
77+
}
78+
}
79+
}

apps/api/src/cache/cache.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { EmoteCacheService } from './emote-cache.service';
3+
import { AuthModule } from '../auth/auth.module';
4+
5+
@Module({
6+
imports: [AuthModule],
7+
providers: [EmoteCacheService],
8+
exports: [EmoteCacheService],
9+
})
10+
export class CacheExtModule {}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Injectable, Logger, Inject } from '@nestjs/common';
2+
import { CACHE_MANAGER } from '@nestjs/cache-manager';
3+
import { Cache } from 'cache-manager';
4+
import { TokenManagerService } from '../auth/token-manager.service';
5+
import { ConfigService } from '@nestjs/config';
6+
import axios from 'axios';
7+
import qs from 'qs';
8+
9+
export interface ChannelAssets {
10+
globalBadges: any;
11+
channelBadges: any;
12+
bttvEmotes: any[];
13+
ffzEmotes: any[];
14+
}
15+
16+
@Injectable()
17+
export class EmoteCacheService {
18+
private readonly logger = new Logger(EmoteCacheService.name);
19+
private readonly ASSETS_TTL_MS = 3600 * 1000; // 1 Hora em milisegundos
20+
21+
constructor(
22+
@Inject(CACHE_MANAGER) private cacheManager: Cache,
23+
private tokenManager: TokenManagerService,
24+
private configService: ConfigService,
25+
) {}
26+
27+
private async axiosGet(url: string, params: any = {}) {
28+
const token = await this.tokenManager.getToken();
29+
const clientId = this.configService.get<string>('TWITCH_CLIENT_ID');
30+
31+
return axios({
32+
method: 'GET',
33+
url: Object.keys(params).length ? `${url}?${qs.stringify(params)}` : url,
34+
headers: {
35+
'Client-ID': clientId,
36+
Authorization: `Bearer ${token}`,
37+
},
38+
});
39+
}
40+
41+
private async getChannelID(channelName: string): Promise<string | null> {
42+
try {
43+
const res = await this.axiosGet('https://api.twitch.tv/helix/users', { login: channelName });
44+
return res.data?.data?.[0]?.id || null;
45+
} catch (error: any) {
46+
this.logger.error(`Error fetching channel ID for ${channelName}: ${error.message}`);
47+
return null;
48+
}
49+
}
50+
51+
async getChannelAssets(channelName: string): Promise<ChannelAssets | null> {
52+
const cacheKey = `assets:${channelName.toLowerCase()}`;
53+
54+
// Tenta buscar no Redis primeiro
55+
const cached = await this.cacheManager.get<ChannelAssets>(cacheKey);
56+
if (cached) {
57+
this.logger.log(`CACHE HIT: Loaded assets for ${channelName} from Redis.`);
58+
return cached;
59+
}
60+
61+
this.logger.log(`CACHE MISS: Fetching external API assets for ${channelName}...`);
62+
63+
const channelId = await this.getChannelID(channelName);
64+
if (!channelId) {
65+
this.logger.warn(`Could not resolve channel ID for ${channelName}`);
66+
return null;
67+
}
68+
69+
try {
70+
const [
71+
globalBadgesRes,
72+
channelBadgesRes,
73+
bttvChannelRes,
74+
bttvGlobalRes,
75+
ffzChannelRes,
76+
ffzGlobalRes
77+
] = await Promise.allSettled([
78+
this.axiosGet('https://api.twitch.tv/helix/chat/badges/global'),
79+
this.axiosGet('https://api.twitch.tv/helix/chat/badges', { broadcaster_id: channelId }),
80+
axios.get(`https://api.betterttv.net/3/cached/users/twitch/${channelId}`),
81+
axios.get(`https://api.betterttv.net/3/cached/emotes/global`),
82+
axios.get(`https://api.frankerfacez.com/v1/room/id/${channelId}`),
83+
axios.get(`https://api.frankerfacez.com/v1/set/global`),
84+
]);
85+
86+
const globalBadges = globalBadgesRes.status === 'fulfilled' ? globalBadgesRes.value.data : null;
87+
const channelBadges = channelBadgesRes.status === 'fulfilled' ? channelBadgesRes.value.data : null;
88+
89+
const bttvEmotes: any[] = [];
90+
if (bttvChannelRes.status === 'fulfilled') {
91+
bttvEmotes.push(...(bttvChannelRes.value.data.channelEmotes || []));
92+
bttvEmotes.push(...(bttvChannelRes.value.data.sharedEmotes || []));
93+
}
94+
if (bttvGlobalRes.status === 'fulfilled') {
95+
bttvEmotes.push(...(bttvGlobalRes.value.data || []));
96+
}
97+
98+
const ffzEmotes: any[] = [];
99+
if (ffzChannelRes.status === 'fulfilled') {
100+
const sets = ffzChannelRes.value.data.sets;
101+
const setKey = Object.keys(sets)[0];
102+
if (setKey) ffzEmotes.push(...sets[setKey].emoticons);
103+
}
104+
if (ffzGlobalRes.status === 'fulfilled') {
105+
ffzEmotes.push(...(ffzGlobalRes.value.data.sets['3']?.emoticons || []));
106+
}
107+
108+
const payload: ChannelAssets = {
109+
globalBadges,
110+
channelBadges,
111+
bttvEmotes,
112+
ffzEmotes,
113+
};
114+
115+
// Salva no Redis com TTL de 1 Hora
116+
await this.cacheManager.set(cacheKey, payload, this.ASSETS_TTL_MS);
117+
118+
return payload;
119+
} catch (error: any) {
120+
this.logger.error(`Error resolving promises for assets: ${error.message}`);
121+
return null;
122+
}
123+
}
124+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Controller, Get, Param } from '@nestjs/common';
2+
import { EmoteCacheService } from '../cache/emote-cache.service';
3+
4+
@Controller('api/assets')
5+
export class AssetsController {
6+
constructor(private readonly emoteCache: EmoteCacheService) {}
7+
8+
@Get(':channel')
9+
async getChannelAssets(@Param('channel') channel: string) {
10+
const assets = await this.emoteCache.getChannelAssets(channel);
11+
if (!assets) {
12+
return { error: 'Failed to fetch assets for channel' };
13+
}
14+
return assets;
15+
}
16+
}

0 commit comments

Comments
 (0)