Files
goodgo-platform/apps/api/src/main.ts
Ho Ngoc Hai 329a821b4a 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>
2026-04-18 15:06:25 +07:00

151 lines
5.4 KiB
TypeScript

import './instrument';
// BigInt cannot be serialized by JSON.stringify by default.
// Polyfill toJSON so Express/NestJS can serialize Prisma BigInt fields.
(BigInt.prototype as any).toJSON = function () {
return this.toString();
};
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { LoggerService, RedisIoAdapter, validateEnv } from '@modules/shared';
import { AppModule } from './app.module';
async function bootstrap() {
validateEnv();
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
rawBody: true,
bodyParser: true,
});
const logger = app.get(LoggerService);
app.useLogger(logger);
// ── API Versioning — global /api/v1/ prefix ──
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/(.*)', method: RequestMethod.GET },
],
});
// ── OpenAPI / Swagger ──
const swaggerConfig = new DocumentBuilder()
.setTitle('Goodgo Platform API')
.setDescription('Real-estate platform API — listings, search, payments, subscriptions, analytics')
.setVersion('1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'JWT',
)
.addTag('auth', 'Authentication & user profile')
.addTag('listings', 'Property listings CRUD & moderation')
.addTag('search', 'Full-text & geo search')
.addTag('payments', 'Payment processing & callbacks')
.addTag('subscriptions', 'Plans, billing & usage metering')
.addTag('admin', 'Admin panel operations')
.addTag('notifications', 'Notification history & preferences')
.addTag('analytics', 'Market reports & price analytics')
.addTag('reviews', 'Property reviews & ratings')
.addTag('mcp', 'Model Context Protocol server transport')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/v1/docs', app, document, {
swaggerOptions: { persistAuthorization: true },
jsonDocumentUrl: 'api/v1/docs-json',
});
// ── WebSocket Adapter (Socket.IO) ──
// Redis pub/sub fan-out for multi-instance broadcasts; falls back to the
// in-memory IoAdapter when Redis is unreachable (single-node / local dev).
const wsAdapter = new RedisIoAdapter(app);
await wsAdapter.connectToRedis();
app.useWebSocketAdapter(wsAdapter);
// ── Security Headers (Helmet) ──
app.use(
helmet({
// CSP relaxed for API — responses are consumed cross-origin by the web frontend
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
imgSrc: ["'self'", 'data:', 'https:', 'blob:'],
connectSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://api.goodgo.vn', 'wss:', 'ws:'],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
// Must allow cross-origin for API consumed by platform.goodgo.vn
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
frameguard: { action: 'deny' },
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}),
);
// ── Permissions-Policy Header ──
app.use((_req: unknown, res: { setHeader: (name: string, value: string) => void }, next: () => void) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(self), payment=(self)',
);
next();
});
// ── Cookie Parser (required for CSRF double-submit pattern) ──
app.use(cookieParser());
// ── CORS ──
const corsOrigins = process.env['CORS_ORIGINS'];
if (!corsOrigins && process.env['NODE_ENV'] === 'production') {
throw new Error('CORS_ORIGINS must be set in production');
}
const allowedOrigins = (corsOrigins ?? 'http://localhost:3000')
.split(',')
.map((o) => o.trim());
app.enableCors({
origin: allowedOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Correlation-Id', 'X-CSRF-Token'],
exposedHeaders: ['X-Correlation-Id'],
credentials: true,
maxAge: 86400,
});
// ── Global Validation Pipe ──
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
disableErrorMessages: process.env['NODE_ENV'] === 'production',
}),
);
// ── Request Body Size Limit ──
const expressApp = app.getHttpAdapter().getInstance();
// ── Trust Proxy (for rate limiting behind reverse proxy) ──
expressApp.set('trust proxy', 1);
// ── Graceful Shutdown ──
app.enableShutdownHooks();
const port = process.env['PORT'] ?? 3001;
await app.listen(port);
logger.log(`API running on http://localhost:${port}/api/v1`, 'Bootstrap');
}
bootstrap();