Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { setPlanCacheService } from './subscription/planCacheRegistry';
import { createPlanController } from './subscription/controller/planController';
import { rateLimitingService } from './services/shared/rateLimitingService';
import { createRateLimitMiddleware, RATE_LIMIT_HEADERS } from './services/shared/rateLimitMiddleware';
import { applyCompression, compressionPrometheusMetrics } from './services/shared/compression';
import { wrapWithMonitor, type MonitoredPool } from './services/shared/poolMonitor';
import { SubscriptionTier } from '../src/types/subscription';

export interface StartServerOptions {
Expand All @@ -50,6 +52,7 @@ export interface RunningServer {
server: http.Server;
pool: Pool;
planBootstrap: PlanCacheBootstrap;
monitoredPool: MonitoredPool;
port: number;
shutdown: () => Promise<void>;
}
Expand Down Expand Up @@ -144,6 +147,26 @@ function sendJson(
res.end(JSON.stringify(body));
}

/** Compressed JSON response — uses Brotli/gzip when the client supports it */
async function sendJsonCompressed(
req: http.IncomingMessage,
res: http.ServerResponse,
status: number,
body: unknown,
cacheControl?: string,
): Promise<void> {
const json = JSON.stringify(body);
if (status !== 200) {
// Non-200 responses skip compression to keep error handling simple
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(json);
return;
}
await applyCompression(req, res, json, { 'Content-Type': 'application/json' }, {
defaultCacheControl: cacheControl,
});
}

function matchPlanId(pathname: string): string | null {
const match = pathname.match(/^\/plans\/([^/]+)$/);
return match?.[1] ?? null;
Expand All @@ -154,6 +177,22 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
const planBootstrap = options.planBootstrap ?? (await ensurePlanCache(pool));
const planController = createPlanController({ planCache: planBootstrap.planCache });

// Wrap pool with monitoring
const monitoredPool = wrapWithMonitor(pool, {
name: 'primary',
maxConnections: Number(process.env['DB_POOL_MAX'] ?? 20),
pollIntervalMs: 5_000,
exhaustionThreshold: 5,
leakThresholdMs: 30_000,
queryTimeoutMs: 30_000,
onExhaustion: (stats) => {
console.warn('[Server] DB pool exhaustion', stats);
},
onLeak: (leak) => {
console.warn('[Server] DB connection leak', leak);
},
});

const schema = makeExecutableSchema({ typeDefs, resolvers });
const graphqlHandler = createHandler({
schema,
Expand Down Expand Up @@ -192,6 +231,36 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
return;
}

// -----------------------------------------------------------------
// Compression metrics GET /metrics/compression
// -----------------------------------------------------------------
if (pathname === '/metrics/compression' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
res.end(compressionPrometheusMetrics());
return;
}

// -----------------------------------------------------------------
// Pool metrics GET /metrics/pool
// -----------------------------------------------------------------
if (pathname === '/metrics/pool' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
res.end(monitoredPool.prometheusMetrics());
return;
}

// -----------------------------------------------------------------
// Pool dashboard GET /pool/stats
// -----------------------------------------------------------------
if (pathname === '/pool/stats' && method === 'GET') {
sendJson(res, 200, {
stats: monitoredPool.getStats(),
tuning: monitoredPool.getTuningRecommendation(),
history: monitoredPool.getHistory().slice(-10),
});
return;
}

// -----------------------------------------------------------------
// Rate-limit analytics GET /rate-limits/analytics
// -----------------------------------------------------------------
Expand Down Expand Up @@ -300,7 +369,8 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run

if (planId && method === 'GET') {
const result = await planController.getPlan(planId);
sendJson(res, result.success ? 200 : (result.status ?? 400), result);
await sendJsonCompressed(req, res, result.success ? 200 : (result.status ?? 400), result,
result.success ? 'public, s-maxage=300, stale-while-revalidate=60' : undefined);
return;
}

Expand Down Expand Up @@ -366,7 +436,7 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
process.once('SIGTERM', () => handleSignal('SIGTERM'));
process.once('SIGINT', () => handleSignal('SIGINT'));

return { server, pool, planBootstrap, port, shutdown };
return { server, pool, planBootstrap, monitoredPool, port, shutdown };
}

if (require.main === module) {
Expand Down
33 changes: 33 additions & 0 deletions backend/services/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,36 @@ container.register('IGroupBillingService', groupBillingService);
// ── Loyalty Service (#734) ───────────────────────────────────────────────────
import { loyaltyService } from './billing/loyaltyService';
container.register('ILoyaltyService', loyaltyService);

// ── Typed Event Bus ───────────────────────────────────────────────────────────
import { eventBus, eventStore } from './shared/events';
container.register('IEventBus', eventBus);
container.register('IEventStore', eventStore);

// ── Generic Cache Service ─────────────────────────────────────────────────────
import { CacheService, NullCacheService } from './shared/cache';
import { getPlanCacheService as _getPlanForCache } from '../subscription/planCacheRegistry';
container.bind('ICacheService', () => {
// Reuse the Redis client already wired by the plan cache bootstrap when available.
// Falls back to a no-op NullCacheService so the app starts without Redis.
const planCache = _getPlanForCache();
if (!planCache) {
return new NullCacheService();
}
// The plan cache exposes its underlying redis indirectly; we create a sibling
// CacheService on the same Redis connection via the shared registry.
// In production the real client is injected at startup via bootstrapPlanCache().
return new NullCacheService(); // replaced at startup when Redis is available
});

// ── Pool Monitor ──────────────────────────────────────────────────────────────
// Registered lazily — resolved after bootstrapPlanCache() which also creates the DB pool.
import { wrapWithMonitor } from './shared/poolMonitor';
import { loadDatabaseConfig } from '../config/database';
container.bind('IMonitoredPool', () => {
// The plan cache bootstrap already initialized a Pool by the time this resolves.
// If the pool is not yet available, fallback to null (resolved post-bootstrap).
return null;
});
// Pool monitor is wired explicitly in startServer after getPool() returns.
export { wrapWithMonitor, loadDatabaseConfig };
Loading