feat(notifications): production-ready WebSocket gateway (TEC-2766)

- Add RedisIoAdapter (shared/infra) for multi-instance Socket.IO fan-out
  with graceful fallback to the in-memory IoAdapter when Redis is
  unreachable.
- Pin Socket.IO heartbeat (pingInterval/pingTimeout/connectTimeout)
  via env-tunable gateway options for reconnect stability.
- Expose Prometheus metrics on /notifications: goodgo_ws_connected_clients
  (Gauge) and goodgo_ws_messages_total (Counter) with namespace/event/
  direction labels. Wired through MetricsService and tracked across
  connect/disconnect + emits.
- Unit tests: RedisIoAdapter connect/fallback/close, new MetricsService
  WS helpers, and gateway metric increments/decrements on auth paths.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 15:06:25 +07:00
parent 5d4ecdeb2f
commit 329a821b4a
13 changed files with 410 additions and 5 deletions

View File

@@ -0,0 +1,90 @@
const hoisted = vi.hoisted(() => ({
redisConnect: vi.fn(),
redisQuit: vi.fn(),
createAdapterMock: vi.fn(() => Symbol('adapter')),
}));
vi.mock('ioredis', () => {
class FakeRedis {
connect = hoisted.redisConnect;
quit = hoisted.redisQuit;
duplicate() {
return new FakeRedis();
}
}
return { default: FakeRedis };
});
vi.mock('@socket.io/redis-adapter', () => ({
createAdapter: hoisted.createAdapterMock,
}));
import { RedisIoAdapter } from '../redis-io.adapter';
function createApp(): unknown {
return {
get: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
getHttpServer: () => undefined,
};
}
describe('RedisIoAdapter', () => {
beforeEach(() => {
hoisted.redisConnect.mockReset();
hoisted.redisQuit.mockReset();
hoisted.createAdapterMock.mockClear();
});
it('connects pub/sub clients and registers the adapter on the server', async () => {
hoisted.redisConnect.mockResolvedValue(undefined);
const adapter = new RedisIoAdapter(createApp() as any);
await adapter.connectToRedis();
expect(hoisted.redisConnect).toHaveBeenCalledTimes(2);
expect(hoisted.createAdapterMock).toHaveBeenCalledTimes(1);
const adapterFn = vi.fn();
const fakeServer = { adapter: adapterFn };
const superProto = Object.getPrototypeOf(Object.getPrototypeOf(adapter)) as object;
vi.spyOn(superProto, 'createIOServer').mockReturnValue(fakeServer);
const result = adapter.createIOServer(3001);
expect(adapterFn).toHaveBeenCalledTimes(1);
expect(result).toBe(fakeServer);
});
it('falls back silently when Redis pub/sub connect fails', async () => {
hoisted.redisConnect.mockRejectedValue(new Error('connection refused'));
const adapter = new RedisIoAdapter(createApp() as any);
await adapter.connectToRedis();
expect(hoisted.createAdapterMock).not.toHaveBeenCalled();
const fakeServer = { adapter: vi.fn() };
const superProto = Object.getPrototypeOf(Object.getPrototypeOf(adapter)) as object;
vi.spyOn(superProto, 'createIOServer').mockReturnValue(fakeServer);
adapter.createIOServer(3001);
expect(fakeServer.adapter).not.toHaveBeenCalled();
});
it('close() quits pub/sub clients', async () => {
hoisted.redisConnect.mockResolvedValue(undefined);
hoisted.redisQuit.mockResolvedValue(undefined);
const adapter = new RedisIoAdapter(createApp() as any);
await adapter.connectToRedis();
await adapter.close();
expect(hoisted.redisQuit).toHaveBeenCalledTimes(2);
});
});

View File

@@ -11,6 +11,7 @@ export {
export { createEncryptionExtension } from './encryption-middleware';
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { RedisIoAdapter } from './redis-io.adapter';
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
export { LoggerService } from './logger.service';
export { EventBusService } from './event-bus.service';

View File

@@ -0,0 +1,85 @@
import type { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import type { ServerOptions } from 'socket.io';
import { LoggerService } from './logger.service';
const CONTEXT = 'RedisIoAdapter';
/**
* Socket.IO adapter backed by Redis pub/sub so WebSocket broadcasts
* fan out across every API instance.
*
* Falls back to the in-memory IoAdapter when Redis cannot be reached,
* so local dev without Redis and single-node deployments still work.
*/
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter> | null = null;
private pubClient: Redis | null = null;
private subClient: Redis | null = null;
private readonly logger: LoggerService;
constructor(app: INestApplicationContext) {
super(app);
this.logger = app.get(LoggerService);
}
async connectToRedis(): Promise<void> {
const host = process.env['REDIS_HOST'] ?? 'localhost';
const port = Number(process.env['REDIS_PORT'] ?? 6379);
const password = process.env['REDIS_PASSWORD'] ?? undefined;
const pub = new Redis({
host,
port,
password,
lazyConnect: true,
enableReadyCheck: false,
maxRetriesPerRequest: 1,
retryStrategy: (times) => Math.min(times * 1000, 5000),
});
const sub = pub.duplicate();
try {
await Promise.all([pub.connect(), sub.connect()]);
} catch (error) {
this.logger.warn(
`Redis pub/sub unavailable — falling back to in-memory adapter: ${
error instanceof Error ? error.message : String(error)
}`,
CONTEXT,
);
await Promise.allSettled([pub.quit(), sub.quit()]);
return;
}
this.pubClient = pub;
this.subClient = sub;
this.adapterConstructor = createAdapter(pub, sub);
this.logger.log(
`Redis pub/sub adapter connected (${host}:${port})`,
CONTEXT,
);
}
override createIOServer(port: number, options?: ServerOptions): unknown {
const server = super.createIOServer(port, options) as {
adapter: (constructor: unknown) => void;
};
if (this.adapterConstructor) {
server.adapter(this.adapterConstructor);
}
return server;
}
override async close(): Promise<void> {
await Promise.allSettled([
this.pubClient?.quit(),
this.subClient?.quit(),
]);
this.pubClient = null;
this.subClient = null;
this.adapterConstructor = null;
}
}