@catbee/utils 0.0.8-rc.2 → 0.0.8-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/build/index.cjs +6591 -0
  2. package/build/index.d.ts +5131 -0
  3. package/build/index.mjs +6321 -0
  4. package/package.json +12 -36
  5. package/build/esm/config.d.ts +0 -121
  6. package/build/esm/config.js +0 -132
  7. package/build/esm/index.d.ts +0 -26
  8. package/build/esm/index.js +0 -49
  9. package/build/esm/servers/server.builder.d.ts +0 -507
  10. package/build/esm/servers/server.builder.js +0 -654
  11. package/build/esm/servers/server.d.ts +0 -255
  12. package/build/esm/servers/server.js +0 -963
  13. package/build/esm/types/api-response.d.ts +0 -151
  14. package/build/esm/types/api-response.js +0 -33
  15. package/build/esm/types/index.d.ts +0 -124
  16. package/build/esm/types/index.js +0 -24
  17. package/build/esm/types/server.d.ts +0 -267
  18. package/build/esm/types/server.js +0 -24
  19. package/build/esm/utils/array.utils.d.ts +0 -167
  20. package/build/esm/utils/array.utils.js +0 -344
  21. package/build/esm/utils/async.utils.d.ts +0 -264
  22. package/build/esm/utils/async.utils.js +0 -619
  23. package/build/esm/utils/cache.utils.d.ts +0 -152
  24. package/build/esm/utils/cache.utils.js +0 -294
  25. package/build/esm/utils/context-store.utils.d.ts +0 -188
  26. package/build/esm/utils/context-store.utils.js +0 -290
  27. package/build/esm/utils/crypto.utils.d.ts +0 -159
  28. package/build/esm/utils/crypto.utils.js +0 -278
  29. package/build/esm/utils/date.utils.d.ts +0 -158
  30. package/build/esm/utils/date.utils.js +0 -383
  31. package/build/esm/utils/decorators.utils.d.ts +0 -511
  32. package/build/esm/utils/decorators.utils.js +0 -1013
  33. package/build/esm/utils/dir.utils.d.ts +0 -195
  34. package/build/esm/utils/dir.utils.js +0 -476
  35. package/build/esm/utils/env.utils.d.ts +0 -376
  36. package/build/esm/utils/env.utils.js +0 -782
  37. package/build/esm/utils/exception.utils.d.ts +0 -229
  38. package/build/esm/utils/exception.utils.js +0 -382
  39. package/build/esm/utils/fs.utils.d.ts +0 -163
  40. package/build/esm/utils/fs.utils.js +0 -348
  41. package/build/esm/utils/http-status-codes.d.ts +0 -265
  42. package/build/esm/utils/http-status-codes.js +0 -294
  43. package/build/esm/utils/id.utils.d.ts +0 -35
  44. package/build/esm/utils/id.utils.js +0 -83
  45. package/build/esm/utils/logger.utils.d.ts +0 -159
  46. package/build/esm/utils/logger.utils.js +0 -304
  47. package/build/esm/utils/middleware.utils.d.ts +0 -99
  48. package/build/esm/utils/middleware.utils.js +0 -235
  49. package/build/esm/utils/obj.utils.d.ts +0 -123
  50. package/build/esm/utils/obj.utils.js +0 -412
  51. package/build/esm/utils/performance.utils.d.ts +0 -135
  52. package/build/esm/utils/performance.utils.js +0 -273
  53. package/build/esm/utils/request.utils.d.ts +0 -85
  54. package/build/esm/utils/request.utils.js +0 -190
  55. package/build/esm/utils/response.utils.d.ts +0 -162
  56. package/build/esm/utils/response.utils.js +0 -261
  57. package/build/esm/utils/stream.utils.d.ts +0 -87
  58. package/build/esm/utils/stream.utils.js +0 -209
  59. package/build/esm/utils/string.utils.d.ts +0 -92
  60. package/build/esm/utils/string.utils.js +0 -164
  61. package/build/esm/utils/type.utils.d.ts +0 -89
  62. package/build/esm/utils/type.utils.js +0 -186
  63. package/build/esm/utils/url.utils.d.ts +0 -140
  64. package/build/esm/utils/url.utils.js +0 -303
  65. package/build/esm/utils/validate.utils.d.ts +0 -176
  66. package/build/esm/utils/validate.utils.js +0 -319
@@ -1,963 +0,0 @@
1
- /*
2
- * The MIT License
3
- *
4
- * Copyright (c) 2025 Catbee Technologies. https://catbee-utils.npm.hprasath.com/license
5
- *
6
- * Permission is hereby granted, free of charge, to any person obtaining a copy
7
- * of this software and associated documentation files (the "Software"), to deal
8
- * in the Software without restriction, including without limitation the rights
9
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- * copies of the Software, and to permit persons to whom the Software is
11
- * furnished to do so, subject to the following conditions:
12
- *
13
- * The above copyright notice and this permission notice shall be included in all
14
- * copies or substantial portions of the Software.
15
- *
16
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- * SOFTWARE.
23
- */
24
- import express from 'express';
25
- import https from 'https';
26
- import { HttpStatusCodes } from '../utils/http-status-codes';
27
- import { createFinalErrorResponse, SuccessResponse } from '../utils/response.utils';
28
- import { errorHandler, requestId, responseTime, setupRequestContext, timeout } from '../utils/middleware.utils';
29
- import { Env } from '../utils/env.utils';
30
- import { getLogger } from '../utils/logger.utils';
31
- import { InternalServerErrorException, ServiceUnavailableException } from '../utils/exception.utils';
32
- import { NotFoundException } from '../utils/exception.utils';
33
- import fs from 'fs';
34
- import { defaultServerConfig } from '../config';
35
- import { deepObjMerge } from '../utils/obj.utils';
36
- import { fileExists } from '../utils/fs.utils';
37
- import { BUILD_MARKER } from './server.builder';
38
- import { isPort } from '../utils/validate.utils';
39
- /**
40
- * Production-ready Express server with enterprise features.
41
- *
42
- * Core Features:
43
- * - Security: Helmet, CORS, rate limiting, timeouts
44
- * - Monitoring: Request logs, metrics, health checks
45
- * - Performance: Compression, caching, static files
46
- * - Reliability: Graceful shutdown, error handling
47
- * - Developer UX: OpenAPI docs, debugging tools
48
- * - Extensibility: Hooks, middleware, custom routes
49
- *
50
- * Designed for microservices and production workloads.
51
- * Includes K8s readiness probes and zero-downtime support.
52
- */
53
- export class ExpressServer {
54
- /** Prometheus client registry for metrics collection */
55
- register = null;
56
- /** HTTP server instance (null when not running) */
57
- server = null;
58
- /** Merged configuration with defaults applied */
59
- config;
60
- /** User-defined lifecycle hooks */
61
- hooks;
62
- /** Global API prefix (from config) */
63
- globalPrefix;
64
- /** Internal fallback router */
65
- rootRouter;
66
- /** User-supplied router */
67
- externalRouter;
68
- /** Internal Express app instance */
69
- app;
70
- /** Set of active WebSocket connections */
71
- connections = new Set();
72
- /** Flag indicating if the server is shutting down */
73
- isShuttingDown = false;
74
- /**
75
- * Collection of registered health check functions.
76
- * These are executed when the health check endpoint is accessed.
77
- */
78
- healthChecks = [];
79
- /** Prometheus metrics for monitoring */
80
- requestCounter;
81
- routeTimings;
82
- requestSizes;
83
- clientIPs;
84
- /** Promise that resolves when initialization (middleware + routes) is complete */
85
- initPromise;
86
- /**
87
- * Initializes server with intelligent defaults and security best practices.
88
- * All settings can be customized via config and hooks.
89
- *
90
- * Default Security:
91
- * - Secure headers (Helmet)
92
- * - Rate limiting
93
- * - Request timeouts
94
- * - Body size limits
95
- * - CORS protection
96
- *
97
- * Default Monitoring:
98
- * - Request/Response logging
99
- * - Prometheus metrics
100
- * - Health checks
101
- * - Request tracing
102
- */
103
- constructor(config, hooks = {}) {
104
- if (ExpressServer.isBuiltServerConfig(config)) {
105
- this.config = config;
106
- }
107
- else {
108
- // Deep merge config with user overrides
109
- this.config = deepObjMerge({}, defaultServerConfig, config);
110
- }
111
- if (!isPort(this.config.port)) {
112
- getLogger().error(`Port must be a valid number between 1 and 65535, got: ${this.config.port}`);
113
- process.exit(1);
114
- }
115
- // Sanitize app name for metrics (replace invalid characters with underscore)
116
- const safeAppName = (this.config.appName || 'express_app').toLowerCase().replace(/[^a-z0-9_]/g, '_');
117
- if (this.config.metrics?.enable) {
118
- const client = ExpressServer.optionalRequire('prom-client');
119
- if (!client) {
120
- getLogger().error({ command: 'npm install prom-client' }, 'prom-client is required for metrics but not installed. Please add it to your dependencies');
121
- process.exit(1);
122
- }
123
- this.register = new client.Registry();
124
- // Initialize Prometheus metrics with sanitized names
125
- this.requestCounter = new client.Counter({
126
- name: `${safeAppName}_http_requests_total`,
127
- help: 'Total HTTP requests',
128
- labelNames: ['method', 'route', 'status'],
129
- registers: [this.register]
130
- });
131
- this.routeTimings = new client.Histogram({
132
- name: `${safeAppName}_http_request_duration_seconds`,
133
- help: 'Duration of HTTP requests by route',
134
- labelNames: ['method', 'route', 'status'],
135
- buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10],
136
- registers: [this.register]
137
- });
138
- this.requestSizes = new client.Histogram({
139
- name: `${safeAppName}_http_request_size_bytes`,
140
- help: 'Size of HTTP request bodies',
141
- labelNames: ['method', 'route'],
142
- buckets: [100, 1000, 10000, 100000, 1000000],
143
- registers: [this.register]
144
- });
145
- this.clientIPs = new client.Counter({
146
- name: `${safeAppName}_http_client_ip_total`,
147
- help: 'Client IP request counter',
148
- labelNames: ['ip', 'method'],
149
- registers: [this.register]
150
- });
151
- // Default system metrics (CPU, memory, event loop lag, etc.)
152
- client.collectDefaultMetrics({
153
- register: this.register,
154
- prefix: `${safeAppName}_`
155
- });
156
- }
157
- // Health checks
158
- if (config.healthCheck?.checks) {
159
- this.healthChecks.push(...config.healthCheck.checks);
160
- }
161
- // Set global prefix (normalize to empty string or "/prefix" without trailing slash)
162
- this.globalPrefix = this.normalizePath(this.config.globalPrefix ?? '', false);
163
- this.hooks = hooks;
164
- this.app = express();
165
- this.rootRouter = express.Router();
166
- // Store initialization promise to prevent race conditions with start()
167
- this.initPromise = this.initialize();
168
- }
169
- /**
170
- * Execute a lifecycle hook safely with comprehensive error handling.
171
- * Prevents hook failures from crashing the server while logging issues.
172
- *
173
- * @param hook Name of the lifecycle hook to execute
174
- * @param args Arguments to pass to the hook function
175
- */
176
- async runHook(hook, ...args) {
177
- try {
178
- const fn = this.hooks[hook];
179
- if (fn)
180
- await fn.apply(null, args);
181
- }
182
- catch (err) {
183
- getLogger().error({ err, hook }, `Error executing ${hook} hook:`);
184
- }
185
- }
186
- /**
187
- * Initialize the Express server with middleware and routes.
188
- */
189
- async initialize() {
190
- await this.runHook('beforeInit', this);
191
- // Set up middleware stack (order is critical)
192
- await this.setupMiddleware();
193
- // Set up default routes and error handling
194
- await this.setupRoutes();
195
- await this.runHook('afterInit', this);
196
- }
197
- /**
198
- * Configure and register all middlewares in the optimal order.
199
- *
200
- * Middleware Order (CRITICAL - don't change without understanding implications):
201
- * 1. Basic server configuration (trust proxy, x-powered-by)
202
- * 2. Request ID generation (for tracing)
203
- * 3. Request context setup (for logging correlation)
204
- * 4. Timeout protection (prevents hanging requests)
205
- * 5. Response time tracking (for performance monitoring)
206
- * 6. Request logging (after ID/context setup)
207
- * 7. Custom request hooks
208
- * 8. Security middleware (rate limiting, CORS, Helmet)
209
- * 9. Response compression
210
- * 10. Static file serving
211
- * 11. Request parsing (body parsing, cookies)
212
- * 12. API documentation (OpenAPI)
213
- * 13. Global headers
214
- * 14. Custom response hooks
215
- */
216
- async setupMiddleware() {
217
- if (this.config.https) {
218
- await this.validateHttpsFiles();
219
- }
220
- // Basic middleware should be first
221
- this.app.disable('x-powered-by');
222
- if (this.config.trustProxy) {
223
- this.app.set('trust proxy', true);
224
- }
225
- // Request ID generation - must be first for proper tracing
226
- this.app.use(requestId({
227
- headerName: this.config.requestId?.headerName,
228
- exposeHeader: this.config.requestId?.exposeHeader,
229
- generator: this.config.requestId?.generator
230
- }));
231
- // Request context setup for logging correlation
232
- this.app.use(setupRequestContext({
233
- headerName: this.config.requestId?.headerName,
234
- autoLog: false
235
- }));
236
- // Early shutdown-awareness middleware (lets load balancers drain connections gracefully)
237
- this.app.use((_req, res, next) => {
238
- if (this.isShuttingDown) {
239
- res.setHeader('Connection', 'close');
240
- return res
241
- .status(HttpStatusCodes.SERVICE_UNAVAILABLE)
242
- .json(new ServiceUnavailableException('Server is shutting down'));
243
- }
244
- next();
245
- return;
246
- });
247
- // Security middleware should come early
248
- if (this.config.helmet) {
249
- const helmet = ExpressServer.optionalRequire('helmet');
250
- if (!helmet) {
251
- getLogger().error({ command: 'npm install helmet' }, 'helmet is required but not installed. Please add it to your dependencies');
252
- process.exit(1);
253
- }
254
- if (typeof this.config.helmet === 'object') {
255
- this.app.use(helmet(this.config.helmet));
256
- }
257
- else {
258
- this.app.use(helmet());
259
- }
260
- }
261
- // CORS middleware should be early
262
- if (this.config.cors) {
263
- const cors = ExpressServer.optionalRequire('cors');
264
- if (!cors) {
265
- getLogger().error({ command: 'npm install cors' }, 'cors is required but not installed. Please add it to your dependencies');
266
- process.exit(1);
267
- }
268
- this.app.use(cors(this.config.cors === true ? {} : this.config.cors));
269
- }
270
- // Global headers
271
- this.app.use((_req, res, next) => {
272
- if (this.config.globalHeaders) {
273
- for (const key in this.config.globalHeaders) {
274
- const value = this.config.globalHeaders[key];
275
- res.setHeader(key, typeof value === 'function' ? value() : value);
276
- }
277
- }
278
- if (this.config.isMicroservice) {
279
- res.setHeader('X-Microservice', this.config.appName || 'express_app');
280
- }
281
- if (this.config.serviceVersion?.enable) {
282
- const version = typeof this.config.serviceVersion?.version === 'function'
283
- ? this.config.serviceVersion.version()
284
- : this.config.serviceVersion?.version;
285
- res.setHeader(this.config.serviceVersion?.headerName || 'x-service-version', version || '0.0.0');
286
- }
287
- next();
288
- });
289
- // Global request timeout protection
290
- if (this.config.requestTimeout) {
291
- this.app.use(timeout(this.config.requestTimeout));
292
- }
293
- // Response time tracking for performance monitoring
294
- if (this.config.responseTime?.enable) {
295
- this.app.use(responseTime({
296
- addHeader: this.config.responseTime.addHeader,
297
- logOnComplete: this.config.responseTime.logOnComplete
298
- }));
299
- }
300
- // Rate limiting should be early to prevent unnecessary processing
301
- if (this.config.rateLimit?.enable) {
302
- const rateLimit = ExpressServer.optionalRequire('express-rate-limit');
303
- if (!rateLimit) {
304
- getLogger().error({ command: 'npm install express-rate-limit' }, 'express-rate-limit is required but not installed. Please add it to your dependencies');
305
- process.exit(1);
306
- }
307
- this.app.use(rateLimit({
308
- windowMs: this.config.rateLimit.windowMs ?? 15 * 60 * 1000,
309
- max: this.config.rateLimit.max ?? 100,
310
- handler: (req, res) => {
311
- const status = HttpStatusCodes.TOO_MANY_REQUESTS;
312
- const response = createFinalErrorResponse(req, status, this.config.rateLimit?.message || 'Too many requests');
313
- res.status(status).json(response);
314
- },
315
- standardHeaders: this.config.rateLimit.standardHeaders ?? true,
316
- legacyHeaders: this.config.rateLimit.legacyHeaders ?? false
317
- }));
318
- }
319
- // Request logging with filtering
320
- if (this.config.requestLogging?.enable) {
321
- this.app.use((req, res, next) => {
322
- if (typeof this.config.requestLogging?.ignorePaths === 'function') {
323
- const skip = this.config.requestLogging?.ignorePaths?.(req, res);
324
- if (skip)
325
- return next();
326
- }
327
- else if (Array.isArray(this.config.requestLogging?.ignorePaths)) {
328
- const skip = this.config.requestLogging?.ignorePaths?.includes(req.path);
329
- if (skip)
330
- return next();
331
- }
332
- const logger = getLogger();
333
- const incomingRequestMetaData = {
334
- requestId: req.id,
335
- method: req.method,
336
- url: req.originalUrl || req.url,
337
- ip: req.ip
338
- };
339
- logger.info(incomingRequestMetaData, 'Incoming Request');
340
- next();
341
- });
342
- }
343
- // Custom request preprocessing hook
344
- if (this.hooks.onRequest) {
345
- this.app.use(this.hooks.onRequest);
346
- }
347
- // Response compression for better performance
348
- if (this.config.compression) {
349
- const compression = ExpressServer.optionalRequire('compression');
350
- if (!compression) {
351
- getLogger().error({ command: 'npm install compression' }, 'compression is required but not installed. Please add it to your dependencies');
352
- process.exit(1);
353
- }
354
- if (typeof this.config.compression === 'object') {
355
- this.app.use(compression(this.config.compression));
356
- }
357
- else {
358
- this.app.use(compression());
359
- }
360
- }
361
- // Static file serving (do NOT normalize filesystem path; only normalize route)
362
- if (this.config.staticFolders) {
363
- this.config.staticFolders.forEach(folder => {
364
- this.app.use(this.normalizePath(folder.path ?? '/'), express.static(folder.directory, {
365
- maxAge: folder.maxAge || 0,
366
- etag: folder.etag !== false,
367
- immutable: folder.immutable === true,
368
- lastModified: folder.lastModified !== false,
369
- cacheControl: folder.cacheControl !== false
370
- }));
371
- getLogger().info(`Serving static folder: ${folder.directory} at path ${folder.path || '/'}`);
372
- });
373
- }
374
- // Request body parsing with size limits
375
- if (this.config.bodyParser) {
376
- if (this.config.bodyParser.json) {
377
- this.app.use(express.json(this.config.bodyParser.json));
378
- }
379
- if (this.config.bodyParser.urlencoded) {
380
- this.app.use(express.urlencoded(this.config.bodyParser.urlencoded));
381
- }
382
- }
383
- // Cookie parser middleware
384
- if (this.config.cookieParser) {
385
- const cookieParser = ExpressServer.optionalRequire('cookie-parser');
386
- if (!cookieParser) {
387
- getLogger().error({ command: 'npm install cookie-parser' }, 'cookie-parser is required but not installed. Please add it to your dependencies');
388
- process.exit(1);
389
- }
390
- if (typeof this.config.cookieParser === 'object') {
391
- this.app.use(cookieParser(undefined, this.config.cookieParser));
392
- }
393
- else {
394
- this.app.use(cookieParser());
395
- }
396
- }
397
- // OpenAPI docs via @scalar/express-api-reference
398
- if (this.config.openApi?.enable) {
399
- try {
400
- const openApiMountPath = this.normalizePath(this.config.openApi.mountPath ?? '/docs', this.config.openApi.withGlobalPrefix);
401
- const openApiFilePath = this.config.openApi.filePath;
402
- if (!openApiFilePath) {
403
- getLogger().error('OpenAPI file path is required');
404
- process.exit(1);
405
- }
406
- const isOpenApiFilePathExists = await fileExists(openApiFilePath);
407
- if (!isOpenApiFilePathExists) {
408
- getLogger().error(`OpenAPI spec file not found at ${openApiFilePath}`);
409
- process.exit(1);
410
- }
411
- if (this.config.openApi?.verbose) {
412
- getLogger().info(`Mounting OpenAPI docs at ${openApiMountPath}`);
413
- getLogger().info(`Using OpenAPI spec file at ${openApiFilePath}`);
414
- }
415
- const apiReference = ExpressServer.optionalRequire('@scalar/express-api-reference').apiReference;
416
- if (!apiReference) {
417
- getLogger().error({ command: 'npm install @scalar/express-api-reference' }, '@scalar/express-api-reference is required for OpenAPI docs but not installed. Please add it to your dependencies');
418
- process.exit(1);
419
- }
420
- this.app.use(openApiMountPath, apiReference({
421
- spec: {
422
- content: await fs.promises.readFile(openApiFilePath, 'utf8')
423
- }
424
- }));
425
- if (this.config.openApi?.verbose) {
426
- getLogger().info(`Mounted OpenAPI docs at ${openApiMountPath}`);
427
- }
428
- }
429
- catch (err) {
430
- getLogger().error({ err }, 'Failed to mount OpenAPI docs');
431
- }
432
- }
433
- // Custom response preprocessing hook (apply global prefix if set)
434
- if (this.hooks.onResponse) {
435
- this.app.use(this.globalPrefix, this.hooks.onResponse);
436
- }
437
- if (this.config.metrics?.enable) {
438
- // Add metrics tracking middleware
439
- this.app.use((req, res, next) => {
440
- const start = process.hrtime();
441
- // Track client IPs
442
- this.clientIPs?.inc({ ip: req.ip, method: req.method });
443
- // Track request sizes (parse safely)
444
- const cl = req.headers['content-length'];
445
- if (cl) {
446
- const size = Number(cl);
447
- if (!Number.isNaN(size) && size >= 0) {
448
- const route = this.normalizeRouteForMetrics(req, res);
449
- this.requestSizes?.observe({ method: req.method, route }, size);
450
- }
451
- }
452
- res.once('finish', () => {
453
- const [seconds, nanoseconds] = process.hrtime(start);
454
- const finalRoute = this.normalizeRouteForMetrics(req, res);
455
- this.requestCounter?.inc({
456
- method: req.method,
457
- route: finalRoute,
458
- status: res.statusCode.toString()
459
- });
460
- this.routeTimings?.observe({
461
- method: req.method,
462
- route: finalRoute,
463
- status: res.statusCode.toString()
464
- }, seconds + nanoseconds / 1e9);
465
- });
466
- next();
467
- });
468
- }
469
- }
470
- /**
471
- * Configure server routes and error handling.
472
- * Sets up in following order:
473
- *
474
- * 1. Built-in routes (health, metrics)
475
- * 2. Application routes
476
- * 3. 404 handler
477
- * 4. Error handler
478
- */
479
- async setupRoutes() {
480
- // Health check endpoint
481
- const healthCheckPath = this.normalizePath(this.config.healthCheck?.path || '/healthz', this.config.healthCheck?.withGlobalPrefix);
482
- this.app.get(healthCheckPath, async (_req, res) => {
483
- try {
484
- if (!this.healthChecks.length) {
485
- return res.status(HttpStatusCodes.OK).json(new SuccessResponse('OK'));
486
- }
487
- const checkResults = await Promise.allSettled(this.healthChecks.map(async ({ name, check }) => {
488
- try {
489
- const status = await Promise.resolve(check());
490
- return { name, status, error: null };
491
- }
492
- catch (error) {
493
- return { name, status: false, error: error.message };
494
- }
495
- }));
496
- const results = checkResults.map(result => {
497
- if (result.status === 'fulfilled')
498
- return result.value;
499
- return { name: 'unknown', status: false, error: result.reason };
500
- });
501
- const allOk = results.every(r => r.status);
502
- const status = allOk ? HttpStatusCodes.OK : HttpStatusCodes.SERVICE_UNAVAILABLE;
503
- const response = new SuccessResponse(allOk ? 'OK' : 'Service unavailable');
504
- if (!allOk)
505
- response.error = true;
506
- if (this.config.healthCheck?.detailed)
507
- response.data = { checks: results };
508
- return res.status(status).json(response);
509
- }
510
- catch {
511
- return res
512
- .status(HttpStatusCodes.INTERNAL_SERVER_ERROR)
513
- .json(new InternalServerErrorException('Health check failed'));
514
- }
515
- });
516
- // Metrics endpoint
517
- if (this.config.metrics?.enable) {
518
- const metricsPath = this.normalizePath(this.config.metrics.path ?? '/metrics', this.config.metrics?.withGlobalPrefix);
519
- this.app.get(metricsPath, async (_req, res) => {
520
- res.set('Content-Type', this.register.contentType);
521
- res.end(await this.register.metrics());
522
- });
523
- }
524
- // Application routes
525
- const routerToUse = this.externalRouter || this.rootRouter;
526
- this.app.use(this.globalPrefix, routerToUse);
527
- // 404 handler (must be after all other routes)
528
- this.app.use((req, res) => {
529
- const status = HttpStatusCodes.NOT_FOUND;
530
- const response = createFinalErrorResponse(req, status, `Route ${req.method.toUpperCase()} ${req.path} not found`);
531
- res.status(status).json(response);
532
- });
533
- // Global error handler (must be the last middleware)
534
- this.app.use((err, req, res, next) => {
535
- // Check if this is a 404 error that should be handled with special logging rules
536
- const isNotFoundError = err instanceof NotFoundException;
537
- const shouldSkipLogging = !this.hooks.onError &&
538
- isNotFoundError &&
539
- this.config.requestLogging?.enable &&
540
- this.config.requestLogging.skipNotFoundRoutes === true;
541
- if (this.hooks.onError) {
542
- // Use custom error handler if provided
543
- this.hooks.onError(err, req, res, next);
544
- }
545
- else {
546
- // Default error handler with logging
547
- const errorHandlerMiddleware = errorHandler({
548
- logErrors: !shouldSkipLogging,
549
- includeDetails: Env.isDev() // Only show stack traces in development
550
- });
551
- errorHandlerMiddleware(err, req, res, next);
552
- }
553
- });
554
- }
555
- /**
556
- * Register a new health check function for monitoring service dependencies.
557
- *
558
- * Health checks are executed when the health endpoint is accessed and
559
- * help determine if the service is ready to handle requests.
560
- *
561
- * Examples:
562
- * - Database connectivity
563
- * - External service availability
564
- * - File system access
565
- * - Memory/CPU usage checks
566
- *
567
- * @param name Unique identifier for the check (used in detailed responses)
568
- * @param check Function returning boolean or Promise<boolean> indicating health
569
- * @returns This instance for method chaining
570
- */
571
- registerHealthCheck(name, check) {
572
- this.healthChecks.push({ name, check });
573
- return this;
574
- }
575
- /**
576
- * Get the underlying Express application instance.
577
- * Use this for advanced Express features not exposed by this wrapper.
578
- *
579
- * @returns The raw Express app instance
580
- */
581
- getApp() {
582
- return this.app;
583
- }
584
- /**
585
- * Get the active HTTP/HTTPS server instance.
586
- * Returns null if the server is not currently running.
587
- *
588
- * @returns The HTTP/HTTPS server instance or null
589
- */
590
- getServer() {
591
- return this.server;
592
- }
593
- /**
594
- * Start the HTTP server and begin listening for requests.
595
- *
596
- * This method:
597
- * - Executes beforeStart hooks
598
- * - Binds to the configured host/port
599
- * - Sets up error handling for startup failures
600
- * - Executes afterStart hooks on success
601
- * - Logs startup information
602
- *
603
- * @returns Promise resolving to the running HTTP server instance
604
- * @throws Error if server fails to start or port is already in use
605
- */
606
- async start() {
607
- // Ensure initialization (middleware + routes) completed before starting
608
- await this.initPromise;
609
- await this.runHook('beforeStart', this.app);
610
- return new Promise((resolve, reject) => {
611
- try {
612
- // Prepare listen arguments with optional host parameter
613
- const listenArgs = [
614
- this.config.port,
615
- this.config.host,
616
- async () => {
617
- const protocol = this.config.https ? 'https' : 'http';
618
- const url = `${protocol}://${this.config.host}:${this.config.port}`;
619
- getLogger().info(`Server running on ${url}`);
620
- if (this.config.healthCheck?.path) {
621
- getLogger().info(`Health check available at ${url}${this.normalizePath(this.config.healthCheck.path, this.config.healthCheck.withGlobalPrefix)}`);
622
- }
623
- if (this.config.metrics?.enable && this.config.metrics.path) {
624
- getLogger().info(`Metrics available at ${url}${this.normalizePath(this.config.metrics.path, this.config.metrics.withGlobalPrefix)}`);
625
- }
626
- if (this.config.openApi?.enable) {
627
- getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
628
- }
629
- if (this.server)
630
- await this.runHook('afterStart', this.server);
631
- resolve(this.server);
632
- }
633
- ];
634
- if (this.config.https) {
635
- const httpsOptions = {
636
- ...this.config.https,
637
- key: fs.readFileSync(this.config.https.key),
638
- cert: fs.readFileSync(this.config.https.cert)
639
- };
640
- if (this.config.https.ca) {
641
- httpsOptions.ca = fs.readFileSync(this.config.https.ca);
642
- }
643
- if (this.config.https.passphrase) {
644
- httpsOptions.passphrase = this.config.https.passphrase;
645
- }
646
- this.server = https.createServer(httpsOptions, this.app).listen(...listenArgs);
647
- }
648
- else {
649
- // Start the HTTP server
650
- this.server = this.app.listen(...listenArgs);
651
- }
652
- // Track connections
653
- this.server.on('connection', (conn) => {
654
- this.connections.add(conn);
655
- conn.on('close', () => this.connections.delete(conn));
656
- });
657
- // Handle server startup errors (port in use, permission denied, etc.)
658
- this.server.on('error', err => {
659
- getLogger().error({ err }, 'Server failed to start');
660
- reject(err);
661
- });
662
- }
663
- catch (error) {
664
- reject(error);
665
- }
666
- });
667
- }
668
- /**
669
- * Stop the HTTP server gracefully.
670
- *
671
- * This method:
672
- * - Executes beforeStop hooks
673
- * - Stops accepting new connections
674
- * - Waits for existing connections to finish
675
- * - Closes the server
676
- * - Executes afterStop hooks
677
- * - Logs shutdown information
678
- *
679
- * Graceful shutdown ensures:
680
- * - No requests are dropped
681
- * - Resources are properly cleaned up
682
- * - Monitoring systems are notified
683
- */
684
- async stop(force = false) {
685
- if (!this.server) {
686
- getLogger().warn('Stop called but server is not running');
687
- return;
688
- }
689
- if (this.isShuttingDown) {
690
- getLogger().warn('Stop called while shutdown is already in progress');
691
- return;
692
- }
693
- this.isShuttingDown = true;
694
- await this.runHook('beforeStop', this.server);
695
- const shutdownTimeout = 10_000; // 10s max wait
696
- const serverClosePromise = new Promise((resolve, reject) => {
697
- this.server.close(async (err) => {
698
- if (err) {
699
- getLogger().error({ err }, 'Error while closing server');
700
- reject(err);
701
- return;
702
- }
703
- this.server = null;
704
- this.isShuttingDown = false;
705
- getLogger().info('Server stopped gracefully');
706
- await this.runHook('afterStop');
707
- resolve();
708
- });
709
- });
710
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Shutdown timeout')), shutdownTimeout));
711
- try {
712
- await Promise.race([serverClosePromise, timeoutPromise]);
713
- }
714
- catch (err) {
715
- getLogger().error({ err }, 'Graceful shutdown timed out');
716
- if (force) {
717
- getLogger().warn('Forcing connection destroy due to shutdown timeout');
718
- }
719
- }
720
- finally {
721
- // Always clean up connections
722
- await this.destroyConnections();
723
- }
724
- }
725
- /**
726
- * Enable graceful shutdown on OS signals for production deployment.
727
- *
728
- * This is essential for:
729
- * - Container orchestration (Docker, Kubernetes)
730
- * - Process managers (PM2, systemd)
731
- * - Load balancer health checks
732
- * - Zero-downtime deployments
733
- *
734
- * @param signals Array of process signals to listen for (default: SIGINT, SIGTERM)
735
- */
736
- enableGracefulShutdown(signals = ['SIGINT', 'SIGTERM']) {
737
- signals.forEach(signal => {
738
- process.on(signal, async () => {
739
- getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
740
- try {
741
- await this.stop();
742
- process.exit(0);
743
- }
744
- catch (err) {
745
- getLogger().error({ err }, 'Error during graceful shutdown, forcing stop...');
746
- try {
747
- await this.stop(true); // fallback to forced shutdown
748
- process.exit(1);
749
- }
750
- catch (forceError) {
751
- getLogger().fatal({ forceError }, 'Forced shutdown failed, exiting hard');
752
- process.exit(1);
753
- }
754
- }
755
- });
756
- });
757
- return this;
758
- }
759
- /**
760
- * Set an externally created base router.
761
- * This will override the internal rootRouter.
762
- */
763
- setBaseRouter(router) {
764
- this.externalRouter = router;
765
- return this;
766
- }
767
- /**
768
- * Create and register a new router (only used if not injecting one externally - use `setBaseRouter` instead).
769
- */
770
- createRouter(prefix = '') {
771
- const router = express.Router();
772
- const path = this.normalizePath(prefix, true);
773
- this.rootRouter.use(path, router);
774
- return router;
775
- }
776
- /**
777
- * Register a new route handler with support for multiple HTTP methods.
778
- * The route is automatically registered under the globalPrefix if set.
779
- *
780
- * @param methods Array of HTTP methods (get, post, put, delete, etc.)
781
- * @param path Route path with Express path patterns support
782
- * @param handlers One or more Express request handlers (middleware + final handler)
783
- * @returns This instance for method chaining
784
- */
785
- registerRoute(methods, path, ...handlers) {
786
- const fullPath = this.normalizePath(path, true);
787
- const methodMap = {
788
- get: this.app.get.bind(this.app),
789
- post: this.app.post.bind(this.app),
790
- put: this.app.put.bind(this.app),
791
- delete: this.app.delete.bind(this.app),
792
- patch: this.app.patch.bind(this.app),
793
- options: this.app.options.bind(this.app),
794
- head: this.app.head.bind(this.app)
795
- };
796
- methods.forEach(m => {
797
- const fn = methodMap[m];
798
- if (fn) {
799
- fn(fullPath, ...handlers);
800
- }
801
- else {
802
- throw new Error(`Unsupported HTTP method: ${m}`);
803
- }
804
- });
805
- return this;
806
- }
807
- /**
808
- * Register custom middleware with optional path restriction.
809
- *
810
- * Use this for:
811
- * - Adding authentication to specific routes
812
- * - Custom logging or validation
813
- * - Request transformation
814
- * - Third-party middleware integration
815
- *
816
- * @param path Optional path prefix or middleware function if no path
817
- * @param middleware Middleware handler (required if path is provided)
818
- * @returns This instance for method chaining
819
- */
820
- registerMiddleware(path, middleware) {
821
- if (typeof path === 'string') {
822
- const normalizedPath = this.normalizePath(path);
823
- if (normalizedPath) {
824
- this.app.use(normalizedPath, middleware);
825
- }
826
- else {
827
- this.app.use(middleware);
828
- }
829
- }
830
- else {
831
- this.app.use(path);
832
- }
833
- return this;
834
- }
835
- /**
836
- * Register one or more middleware functions to be applied globally.
837
- * This is a simpler alternative to registerMiddleware when you just want
838
- * to add middleware without path restrictions.
839
- *
840
- * @param middlewares One or more Express middleware functions
841
- * @returns This instance for method chaining
842
- */
843
- useMiddleware(...middlewares) {
844
- middlewares.forEach(middleware => {
845
- this.app.use(middleware);
846
- });
847
- return this;
848
- }
849
- /**
850
- * Get Prometheus registry (to add custom counters/histograms)
851
- *
852
- * @return {*} {client.Registry}
853
- */
854
- getMetricsRegistry() {
855
- if (!this.config.metrics?.enable) {
856
- throw new Error('Metrics are not enabled in the server configuration');
857
- }
858
- return this.register;
859
- }
860
- /**
861
- * Get server configuration
862
- *
863
- * @return {*} {ServerConfig}
864
- */
865
- getConfig() {
866
- return this.config;
867
- }
868
- /**
869
- * Wait until server initialization (middleware + routes) has completed.
870
- * Useful for integration tests that inspect app before starting.
871
- */
872
- async waitUntilReady() {
873
- await this.initPromise;
874
- }
875
- normalizePath(path, withGlobalPrefix = false) {
876
- const sanitize = (p) => {
877
- return ('/' +
878
- p
879
- .trim()
880
- .replace(/^\/+/, '') // remove leading slashes
881
- .replace(/\/{2,}/g, '/') // collapse multiple slashes
882
- .replace(/\/+$/, '')); // remove trailing slash
883
- };
884
- // Resolve global prefix if enabled
885
- const prefix = withGlobalPrefix && this.globalPrefix ? sanitize(this.globalPrefix) : '';
886
- // If path is invalid, default to prefix or root
887
- if (typeof path !== 'string' || !path.trim()) {
888
- return prefix || '/';
889
- }
890
- return sanitize(prefix + '/' + path);
891
- }
892
- normalizeRouteForMetrics(req, res) {
893
- // Prevent high cardinality metrics by normalizing routes
894
- if (req?.route?.path) {
895
- // Use Express route pattern instead of actual URL
896
- return req.route.path;
897
- }
898
- // Group common patterns
899
- if (res.statusCode === 404)
900
- return '/404';
901
- const path = (req.path || 'unknown').split('?')[0];
902
- // Replace IDs and UUIDs with placeholders
903
- return path
904
- .replace(/\/[0-9]+/g, '/:id')
905
- .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '/:uuid');
906
- }
907
- /**
908
- * Destroy all active connections (gracefully if possible).
909
- * If a connection does not close cleanly, it will be force-destroyed.
910
- */
911
- async destroyConnections() {
912
- const total = this.connections.size;
913
- if (total === 0) {
914
- getLogger().debug('No active connections to close');
915
- return;
916
- }
917
- const timeoutMs = 5000;
918
- await Promise.race([
919
- Promise.all(Array.from(this.connections).map(conn => new Promise(resolve => {
920
- conn.end(() => {
921
- if (!conn.destroyed)
922
- conn.destroy();
923
- resolve();
924
- });
925
- conn.on('error', () => {
926
- conn.destroy();
927
- resolve();
928
- });
929
- }))),
930
- new Promise(resolve => setTimeout(resolve, timeoutMs))
931
- ]);
932
- this.connections.clear();
933
- getLogger().info(`Closed ${total} active connections`);
934
- }
935
- async validateHttpsFiles() {
936
- if (!(await fileExists(this.config.https.key))) {
937
- getLogger().error(`HTTPS key file not found: ${this.config.https.key}`);
938
- process.exit(1);
939
- }
940
- if (!(await fileExists(this.config.https.cert))) {
941
- getLogger().error(`HTTPS cert file not found: ${this.config.https.cert}`);
942
- process.exit(1);
943
- }
944
- if (this.config.https.ca && !(await fileExists(this.config.https.ca))) {
945
- getLogger().error(`HTTPS CA file not found: ${this.config.https.ca}`);
946
- process.exit(1);
947
- }
948
- }
949
- static isBuiltServerConfig(config) {
950
- if (config[BUILD_MARKER]) {
951
- return true;
952
- }
953
- return false;
954
- }
955
- static optionalRequire(name) {
956
- try {
957
- return require(name);
958
- }
959
- catch {
960
- return null;
961
- }
962
- }
963
- }