• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 23326817002

20 Mar 2026 02:42AM UTC coverage: 89.826% (-0.05%) from 89.876%
23326817002

push

github

BillChirico
test: raise coverage threshold for PR 332

6338 of 7450 branches covered (85.07%)

Branch coverage included in aggregate %.

10711 of 11530 relevant lines covered (92.9%)

225.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.9
/src/api/server.js
1
/**
2
 * Express API Server
3
 * HTTP server that runs alongside the Discord WebSocket client
4
 */
5

6
import express from 'express';
7
import { error, info, warn } from '../logger.js';
8
import { PerformanceMonitor } from '../modules/performanceMonitor.js';
9
import apiRouter from './index.js';
10
import { redisRateLimit } from './middleware/redisRateLimit.js';
11
import { stopAuthCleanup } from './routes/auth.js';
12
import { swaggerSpec } from './swagger.js';
13
import { stopGuildCacheCleanup } from './utils/discordApi.js';
14
import { setupAuditStream, stopAuditStream } from './ws/auditStream.js';
15
import { setupLogStream, stopLogStream } from './ws/logStream.js';
16

17
/** @type {import('node:http').Server | null} */
18
let server = null;
48✔
19

20
/** @type {ReturnType<typeof redisRateLimit> | null} */
21
let rateLimiter = null;
48✔
22

23
/**
24
 * Creates and configures the Express application.
25
 *
26
 * @param {import('discord.js').Client} client - Discord client instance
27
 * @param {import('pg').Pool | null} dbPool - PostgreSQL connection pool
28
 * @returns {import('express').Application} Configured Express app
29
 */
30
export function createApp(client, dbPool) {
31
  const app = express();
538✔
32

33
  // Trust one proxy hop (e.g. Railway, Docker) so req.ip reflects the real client IP
34
  app.set('trust proxy', 1);
538✔
35

36
  // Store references for route handlers
37
  app.locals.client = client;
538✔
38
  app.locals.dbPool = dbPool;
538✔
39

40
  // CORS - must come BEFORE body parser so error responses include CORS headers
41
  const dashboardUrl = process.env.DASHBOARD_URL;
538✔
42
  if (dashboardUrl === '*') {
538!
43
    warn('DASHBOARD_URL is set to wildcard "*" — this is insecure; set a specific origin');
×
44
  }
45
  app.use((req, res, next) => {
538✔
46
    if (!dashboardUrl) return next();
547✔
47
    res.set('Access-Control-Allow-Origin', dashboardUrl);
2✔
48
    res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, OPTIONS');
2✔
49
    res.set('Access-Control-Allow-Headers', 'Content-Type, x-api-secret, Authorization');
2✔
50
    res.set('Access-Control-Allow-Credentials', 'true');
2✔
51
    res.set('Vary', 'Origin');
2✔
52
    if (req.method === 'OPTIONS') {
2✔
53
      return res.status(204).end();
1✔
54
    }
55
    next();
1✔
56
  });
57

58
  // Body parsing
59
  const bodyLimit = process.env.API_BODY_LIMIT || '100kb';
538✔
60
  app.use(express.json({ limit: bodyLimit }));
538✔
61

62
  // Rate limiting — destroy any leaked limiter from a prior createApp call
63
  if (rateLimiter) {
538✔
64
    rateLimiter.destroy();
498✔
65
    rateLimiter = null;
498✔
66
  }
67
  rateLimiter = redisRateLimit();
538✔
68
  app.use((req, res, next) => {
538✔
69
    if (req.path === '/api/v1/health') return next();
545✔
70
    return rateLimiter(req, res, next);
526✔
71
  });
72

73
  // Raw OpenAPI spec (JSON) — public for Mintlify
74
  app.get('/api/docs.json', (_req, res) => res.json(swaggerSpec));
538✔
75

76
  // Response time tracking for performance monitoring
77
  app.use('/api/v1', (req, res, next) => {
538✔
78
    const start = Date.now();
544✔
79
    res.on('finish', () => {
544✔
80
      const duration = Date.now() - start;
544✔
81
      const label = `${req.method} ${req.path}`;
544✔
82
      PerformanceMonitor.getInstance().recordResponseTime(label, duration, 'api');
544✔
83
    });
84
    next();
544✔
85
  });
86

87
  // Mount API routes under /api/v1
88
  app.use('/api/v1', apiRouter);
538✔
89

90
  // Error handling middleware
91
  app.use((err, _req, res, _next) => {
538✔
92
    // Pass through status code from body-parser or other middleware (e.g., 400 for malformed JSON)
93
    // Only use err.status/err.statusCode if it's a valid 4xx client error code
94
    // Otherwise default to 500 for server errors
95
    const statusCode = err.status ?? err.statusCode;
3✔
96
    const status = statusCode >= 400 && statusCode < 500 ? statusCode : 500;
3✔
97

98
    // Only log stack trace for server errors (5xx), not client errors (4xx)
99
    const logMeta = { error: err.message };
3✔
100
    if (!statusCode || statusCode >= 500) logMeta.stack = err.stack;
3✔
101
    error('Unhandled API error', logMeta);
3✔
102

103
    res.status(status).json({ error: status < 500 ? err.message : 'Internal server error' });
3✔
104
  });
105

106
  return app;
538✔
107
}
108

109
/**
110
 * Starts the Express HTTP server.
111
 *
112
 * @param {import('discord.js').Client} client - Discord client instance
113
 * @param {import('pg').Pool | null} dbPool - PostgreSQL connection pool
114
 * @param {Object} [options] - Additional options
115
 * @param {import('../transports/websocket.js').WebSocketTransport} [options.wsTransport] - WebSocket transport for log streaming
116
 * @returns {Promise<import('node:http').Server>} The HTTP server instance
117
 */
118
export async function startServer(client, dbPool, options = {}) {
8✔
119
  if (server) {
8✔
120
    warn('startServer called while a server is already running — closing orphaned server');
1✔
121
    await stopServer();
1✔
122
  }
123

124
  const app = createApp(client, dbPool);
8✔
125
  // Railway injects PORT at runtime; keep BOT_API_PORT as local/dev fallback.
126
  const portEnv = process.env.PORT ?? process.env.BOT_API_PORT;
8✔
127
  const parsed = portEnv != null ? Number.parseInt(portEnv, 10) : NaN;
8!
128
  const isValidPort = !Number.isNaN(parsed) && parsed >= 0 && parsed <= 65535;
8✔
129
  if (portEnv != null && !isValidPort) {
8✔
130
    warn('Invalid port value, falling back to default', {
1✔
131
      provided: portEnv,
132
      parsed,
133
      fallback: 3001,
134
    });
135
  }
136
  const port = isValidPort ? parsed : 3001;
8✔
137

138
  return new Promise((resolve, reject) => {
8✔
139
    server = app.listen(port, () => {
8✔
140
      info('API server started', { port });
8✔
141

142
      // Attach WebSocket log stream if transport provided
143
      if (options.wsTransport) {
8✔
144
        try {
2✔
145
          setupLogStream(server, options.wsTransport);
2✔
146
        } catch (err) {
147
          error('Failed to setup WebSocket log stream', { error: err.message });
1✔
148
          // Non-fatal — HTTP server still works without WS streaming
149
        }
150
      }
151

152
      // Attach audit log real-time WebSocket stream
153
      try {
8✔
154
        setupAuditStream(server);
8✔
155
      } catch (err) {
156
        error('Failed to setup audit log WebSocket stream', { error: err.message });
×
157
        // Non-fatal — HTTP server still works without audit WS streaming
158
      }
159

160
      resolve(server);
8✔
161
    });
162
    server.once('error', (err) => {
8✔
163
      error('API server failed to start', { error: err.message });
×
164
      server = null;
×
165
      reject(err);
×
166
    });
167
  });
168
}
169

170
/**
171
 * Stops the Express HTTP server gracefully.
172
 *
173
 * @returns {Promise<void>}
174
 */
175
export async function stopServer() {
176
  // Stop WebSocket log stream before closing HTTP server
177
  await stopLogStream();
29✔
178

179
  // Stop audit log WebSocket stream
180
  await stopAuditStream();
29✔
181

182
  stopAuthCleanup();
29✔
183
  stopGuildCacheCleanup();
29✔
184

185
  if (rateLimiter) {
29✔
186
    rateLimiter.destroy();
16✔
187
    rateLimiter = null;
16✔
188
  }
189

190
  if (!server) {
29✔
191
    warn('API server stop called but no server running');
21✔
192
    return;
21✔
193
  }
194

195
  const SHUTDOWN_TIMEOUT_MS = 5_000;
8✔
196
  const closing = server;
8✔
197

198
  return new Promise((resolve, reject) => {
8✔
199
    let settled = false;
8✔
200

201
    const timeout = setTimeout(() => {
8✔
202
      if (settled) return;
×
203
      settled = true;
×
204
      warn('API server close timed out, forcing connections closed');
×
205
      if (typeof closing.closeAllConnections === 'function') {
×
206
        closing.closeAllConnections();
×
207
      }
208
      server = null;
×
209
      resolve();
×
210
    }, SHUTDOWN_TIMEOUT_MS);
211

212
    closing.close((err) => {
8✔
213
      clearTimeout(timeout);
8✔
214
      if (settled) return;
8!
215
      settled = true;
8✔
216
      server = null;
8✔
217
      if (err) {
8!
218
        error('Error closing API server', { error: err.message });
×
219
        reject(err);
×
220
      } else {
221
        info('API server stopped');
8✔
222
        resolve();
8✔
223
      }
224
    });
225
  });
226
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc