@catbee/utils 1.1.0 → 2.0.0-next.1
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.
- package/LICENSE +1 -1
- package/README.md +79 -43
- package/array/index.cjs +355 -0
- package/array/index.d.ts +317 -0
- package/array/index.mjs +327 -0
- package/async/index.cjs +484 -0
- package/async/index.d.ts +299 -0
- package/async/index.mjs +463 -0
- package/cache/index.cjs +292 -0
- package/cache/index.d.ts +179 -0
- package/cache/index.mjs +290 -0
- package/config/index.cjs +150 -0
- package/config/index.d.ts +88 -0
- package/config/index.mjs +143 -0
- package/context-store/index.cjs +267 -0
- package/context-store/index.d.ts +216 -0
- package/context-store/index.mjs +261 -0
- package/crypto/index.cjs +182 -0
- package/crypto/index.d.ts +187 -0
- package/crypto/index.mjs +166 -0
- package/date/index.cjs +340 -0
- package/date/index.d.ts +214 -0
- package/date/index.mjs +326 -0
- package/decorators/index.cjs +2051 -0
- package/decorators/index.d.ts +708 -0
- package/decorators/index.mjs +2010 -0
- package/dir/index.cjs +417 -0
- package/dir/index.d.ts +219 -0
- package/dir/index.mjs +390 -0
- package/env/index.cjs +745 -0
- package/env/index.d.ts +403 -0
- package/env/index.mjs +742 -0
- package/exception/index.cjs +362 -0
- package/exception/index.d.ts +256 -0
- package/exception/index.mjs +338 -0
- package/fs/index.cjs +287 -0
- package/fs/index.d.ts +229 -0
- package/fs/index.mjs +258 -0
- package/http-status-codes/index.cjs +96 -0
- package/http-status-codes/index.d.ts +291 -0
- package/http-status-codes/index.mjs +94 -0
- package/id/index.cjs +62 -0
- package/id/index.d.ts +61 -0
- package/id/index.mjs +56 -0
- package/index.cjs +218 -0
- package/index.d.ts +51 -0
- package/index.mjs +51 -0
- package/logger/index.cjs +334 -0
- package/logger/index.d.ts +213 -0
- package/logger/index.mjs +313 -0
- package/middleware/index.cjs +177 -0
- package/middleware/index.d.ts +127 -0
- package/middleware/index.mjs +170 -0
- package/obj/index.cjs +305 -0
- package/obj/index.d.ts +160 -0
- package/obj/index.mjs +289 -0
- package/package.json +172 -20
- package/performance/index.cjs +231 -0
- package/performance/index.d.ts +162 -0
- package/performance/index.mjs +225 -0
- package/request/index.cjs +202 -0
- package/request/index.d.ts +265 -0
- package/request/index.mjs +194 -0
- package/response/index.cjs +234 -0
- package/response/index.d.ts +342 -0
- package/response/index.mjs +222 -0
- package/server/index.cjs +1631 -0
- package/server/index.d.ts +809 -0
- package/server/index.mjs +1622 -0
- package/stream/index.cjs +151 -0
- package/stream/index.d.ts +114 -0
- package/stream/index.mjs +144 -0
- package/string/index.cjs +109 -0
- package/string/index.d.ts +126 -0
- package/string/index.mjs +95 -0
- package/type/index.cjs +129 -0
- package/type/index.d.ts +131 -0
- package/type/index.mjs +119 -0
- package/types/index.cjs +34 -0
- package/types/index.d.ts +798 -0
- package/types/index.mjs +32 -0
- package/url/index.cjs +199 -0
- package/url/index.d.ts +166 -0
- package/url/index.mjs +187 -0
- package/validation/index.cjs +259 -0
- package/validation/index.d.ts +209 -0
- package/validation/index.mjs +231 -0
- package/build/index.cjs +0 -7694
- package/build/index.d.ts +0 -5792
- package/build/index.mjs +0 -7386
package/server/index.mjs
ADDED
|
@@ -0,0 +1,1622 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The MIT License
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Catbee Technologies. https://catbee.in/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
|
+
|
|
25
|
+
import express from 'express';
|
|
26
|
+
import https from 'https';
|
|
27
|
+
import { HttpStatusCodes } from '@catbee/utils/http-status-codes';
|
|
28
|
+
import { SuccessResponse, createFinalErrorResponse } from '@catbee/utils/response';
|
|
29
|
+
import { requestId, setupRequestContext, timeout, responseTime, errorHandler } from '@catbee/utils/middleware';
|
|
30
|
+
import { Env } from '@catbee/utils/env';
|
|
31
|
+
import { getLogger } from '@catbee/utils/logger';
|
|
32
|
+
import { ServiceUnavailableException, InternalServerErrorException, NotFoundException } from '@catbee/utils/exception';
|
|
33
|
+
import { getCatbeeServerGlobalConfig } from '@catbee/utils/config';
|
|
34
|
+
import { deepObjMerge, deepClone } from '@catbee/utils/obj';
|
|
35
|
+
import { fileExists, readFile, readFileSync } from '@catbee/utils/fs';
|
|
36
|
+
import { isPort } from '@catbee/utils/validation';
|
|
37
|
+
import { optionalRequire } from '@catbee/utils/async';
|
|
38
|
+
import { uuid } from '@catbee/utils/id';
|
|
39
|
+
|
|
40
|
+
var __defProp = Object.defineProperty;
|
|
41
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
42
|
+
var BUILD_MARKER = Symbol.for("catbee.express.server.build");
|
|
43
|
+
var ServerConfigBuilder = class {
|
|
44
|
+
static {
|
|
45
|
+
__name(this, "ServerConfigBuilder");
|
|
46
|
+
}
|
|
47
|
+
config = {};
|
|
48
|
+
/**
|
|
49
|
+
* Validates that a port number is valid and usable.
|
|
50
|
+
*
|
|
51
|
+
* @private
|
|
52
|
+
* @param port - The port number to validate
|
|
53
|
+
* @throws {Error} If port is not an integer or is outside the valid range (1-65535)
|
|
54
|
+
*/
|
|
55
|
+
validatePort(port) {
|
|
56
|
+
if (!isPort(port)) {
|
|
57
|
+
throw new Error(`Port must be a valid number between 1 and 65535, got: ${port}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sets the port the server will listen on.
|
|
62
|
+
*
|
|
63
|
+
* @param port - The port number (1-65535)
|
|
64
|
+
* @returns The builder instance for chaining
|
|
65
|
+
* @throws {Error} If port is invalid
|
|
66
|
+
* @default 3000 (can be overridden via PORT env variable)
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* builder.withPort(3000)
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
withPort(port) {
|
|
74
|
+
this.validatePort(port);
|
|
75
|
+
this.config.port = port;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sets the hostname the server will bind to.
|
|
80
|
+
*
|
|
81
|
+
* @param host - The hostname (e.g., 'localhost', '0.0.0.0', '127.0.0.1')
|
|
82
|
+
* @returns The builder instance for chaining
|
|
83
|
+
* @default '0.0.0.0' (can be overridden via HOST env variable)
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* builder.withHost('0.0.0.0') // Listen on all interfaces
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
withHost(host) {
|
|
91
|
+
this.config.host = host;
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Configures Cross-Origin Resource Sharing (CORS) for the server.
|
|
96
|
+
*
|
|
97
|
+
* @param opts - CORS options object or boolean (true to enable with defaults, false to disable)
|
|
98
|
+
* @returns The builder instance for chaining
|
|
99
|
+
* @default false (CORS is disabled by default)
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* // Enable CORS with default options
|
|
104
|
+
* builder.withCors(true)
|
|
105
|
+
*
|
|
106
|
+
* // Configure CORS with specific options
|
|
107
|
+
* builder.withCors({
|
|
108
|
+
* origin: ['https://example.com'],
|
|
109
|
+
* methods: ['GET', 'POST']
|
|
110
|
+
* })
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
withCors(opts) {
|
|
114
|
+
this.config.cors = opts;
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Enables CORS with default settings
|
|
119
|
+
*
|
|
120
|
+
* @returns The builder instance for chaining
|
|
121
|
+
*/
|
|
122
|
+
enableCors() {
|
|
123
|
+
return this.withCors(true);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Disables CORS
|
|
127
|
+
*
|
|
128
|
+
* @returns The builder instance for chaining
|
|
129
|
+
*/
|
|
130
|
+
disableCors() {
|
|
131
|
+
return this.withCors(false);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Configures the Helmet middleware for setting HTTP security headers.
|
|
135
|
+
*
|
|
136
|
+
* @param opts - Helmet options object or boolean (true to enable with defaults, false to disable)
|
|
137
|
+
* @returns The builder instance for chaining
|
|
138
|
+
* @default false (Helmet is disabled by default)
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* // Enable Helmet with default settings
|
|
143
|
+
* builder.withHelmet(true)
|
|
144
|
+
*
|
|
145
|
+
* // Configure Helmet with specific options
|
|
146
|
+
* builder.withHelmet({
|
|
147
|
+
* contentSecurityPolicy: false,
|
|
148
|
+
* xssFilter: true
|
|
149
|
+
* })
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
withHelmet(opts) {
|
|
153
|
+
this.config.helmet = opts;
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Enables Helmet with default settings
|
|
158
|
+
*
|
|
159
|
+
* @returns The builder instance for chaining
|
|
160
|
+
*/
|
|
161
|
+
enableHelmet() {
|
|
162
|
+
return this.withHelmet(true);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Disables Helmet
|
|
166
|
+
*
|
|
167
|
+
* @returns The builder instance for chaining
|
|
168
|
+
*/
|
|
169
|
+
disableHelmet() {
|
|
170
|
+
return this.withHelmet(false);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Configures response compression middleware.
|
|
174
|
+
*
|
|
175
|
+
* @param opts - Compression options object or boolean (true to enable with defaults, false to disable)
|
|
176
|
+
* @returns The builder instance for chaining
|
|
177
|
+
* @default false (Compression is disabled by default)
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```typescript
|
|
181
|
+
* // Enable compression with default settings
|
|
182
|
+
* builder.withCompression(true)
|
|
183
|
+
*
|
|
184
|
+
* // Configure compression with specific options
|
|
185
|
+
* builder.withCompression({
|
|
186
|
+
* level: 6,
|
|
187
|
+
* threshold: 1024
|
|
188
|
+
* })
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
withCompression(opts) {
|
|
192
|
+
this.config.compression = opts;
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Enables compression with default settings
|
|
197
|
+
*
|
|
198
|
+
* @returns The builder instance for chaining
|
|
199
|
+
*/
|
|
200
|
+
enableCompression() {
|
|
201
|
+
return this.withCompression(true);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Disables compression
|
|
205
|
+
*
|
|
206
|
+
* @returns The builder instance for chaining
|
|
207
|
+
*/
|
|
208
|
+
disableCompression() {
|
|
209
|
+
return this.withCompression(false);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Configures rate limiting to protect against brute-force attacks.
|
|
213
|
+
*
|
|
214
|
+
* @param opts - Rate limit configuration options
|
|
215
|
+
* @returns The builder instance for chaining
|
|
216
|
+
* @default - { enable: false, windowMs: 15 * 60 * 1000, max: 100, message: 'Too many requests', standardHeaders: true, legacyHeaders: false }
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```typescript
|
|
220
|
+
* builder.withRateLimit({
|
|
221
|
+
* enable: true,
|
|
222
|
+
* windowMs: 15 * 60 * 1000, // 15 minutes
|
|
223
|
+
* max: 100 // limit each IP to 100 requests per windowMs
|
|
224
|
+
* })
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
withRateLimit(opts) {
|
|
228
|
+
this.mergeConfig("rateLimit", opts);
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Enables rate limiting with default or custom settings
|
|
233
|
+
*
|
|
234
|
+
* @param opts - Optional rate limit configuration (max requests, window, etc.)
|
|
235
|
+
* @returns The builder instance for chaining
|
|
236
|
+
*/
|
|
237
|
+
enableRateLimit(opts = {}) {
|
|
238
|
+
return this.setEnabled("rateLimit", true, opts);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Disables rate limiting
|
|
242
|
+
*
|
|
243
|
+
* @returns The builder instance for chaining
|
|
244
|
+
*/
|
|
245
|
+
disableRateLimit() {
|
|
246
|
+
return this.setEnabled("rateLimit", false);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Configures HTTP request logging middleware.
|
|
250
|
+
*
|
|
251
|
+
* @param opts - Request logging configuration options
|
|
252
|
+
* @returns The builder instance for chaining
|
|
253
|
+
* @default - { enable: true in dev/false in prod, ignorePaths: ['/healthz', '/favicon.ico', '/metrics', '/docs', '/.well-known'], skipNotFoundRoutes: false }
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```typescript
|
|
257
|
+
* builder.withRequestLogging({
|
|
258
|
+
* enable: true,
|
|
259
|
+
* ignorePaths: ['/health', '/metrics'],
|
|
260
|
+
* skipNotFoundRoutes: true
|
|
261
|
+
* })
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
withRequestLogging(opts) {
|
|
265
|
+
this.mergeConfig("requestLogging", opts);
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Enables request logging with default or custom settings
|
|
270
|
+
*
|
|
271
|
+
* @param opts - Optional request logging configuration
|
|
272
|
+
* @returns The builder instance for chaining
|
|
273
|
+
*/
|
|
274
|
+
enableRequestLogging(opts = {}) {
|
|
275
|
+
return this.setEnabled("requestLogging", true, opts);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Disables request logging
|
|
279
|
+
*
|
|
280
|
+
* @returns The builder instance for chaining
|
|
281
|
+
*/
|
|
282
|
+
disableRequestLogging() {
|
|
283
|
+
return this.setEnabled("requestLogging", false);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Configures server metrics collection and endpoints.
|
|
287
|
+
*
|
|
288
|
+
* @param opts - Metrics configuration options
|
|
289
|
+
* @returns The builder instance for chaining
|
|
290
|
+
* @default - { enable: false, path: '/metrics', withGlobalPrefix: false }
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```typescript
|
|
294
|
+
* builder.withMetrics({
|
|
295
|
+
* enable: true,
|
|
296
|
+
* path: '/metrics'
|
|
297
|
+
* })
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
withMetrics(opts) {
|
|
301
|
+
this.mergeConfig("metrics", opts);
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Enables Prometheus metrics collection and endpoint
|
|
306
|
+
*
|
|
307
|
+
* @param opts - Optional metrics configuration
|
|
308
|
+
* @returns The builder instance for chaining
|
|
309
|
+
*/
|
|
310
|
+
enableMetrics(opts = {}) {
|
|
311
|
+
return this.setEnabled("metrics", true, opts);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Disables Prometheus metrics
|
|
315
|
+
*
|
|
316
|
+
* @returns The builder instance for chaining
|
|
317
|
+
*/
|
|
318
|
+
disableMetrics() {
|
|
319
|
+
return this.setEnabled("metrics", false);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Configures server health check endpoint.
|
|
323
|
+
*
|
|
324
|
+
* @param opts - Health check configuration options
|
|
325
|
+
* @returns The builder instance for chaining
|
|
326
|
+
* @default - { path: '/healthz', detailed: true, withGlobalPrefix: false }
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* ```typescript
|
|
330
|
+
* builder.withHealthCheck({
|
|
331
|
+
* path: '/health',
|
|
332
|
+
* detailed: true
|
|
333
|
+
* })
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
withHealthCheck(opts) {
|
|
337
|
+
this.mergeConfig("healthCheck", opts);
|
|
338
|
+
return this;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Configures OpenAPI/Swagger documentation for the API.
|
|
342
|
+
*
|
|
343
|
+
* @param opts - OpenAPI configuration options
|
|
344
|
+
* @returns The builder instance for chaining
|
|
345
|
+
* @default - { enable: false, mountPath: '/docs', verbose: false, withGlobalPrefix: false }
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```typescript
|
|
349
|
+
* builder.withOpenApi({
|
|
350
|
+
* enable: true,
|
|
351
|
+
* path: '/api-docs',
|
|
352
|
+
* filePath: './openapi.yaml'
|
|
353
|
+
* })
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
withOpenApi(opts) {
|
|
357
|
+
this.mergeConfig("openApi", opts);
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Enables OpenAPI documentation with required file path
|
|
362
|
+
*
|
|
363
|
+
* @param filePath - Path to OpenAPI specification file (required)
|
|
364
|
+
* @param opts - Optional OpenAPI configuration
|
|
365
|
+
* @returns The builder instance for chaining
|
|
366
|
+
*/
|
|
367
|
+
enableOpenApi(filePath, opts = {}) {
|
|
368
|
+
this.setEnabled("openApi", true, {
|
|
369
|
+
filePath,
|
|
370
|
+
...opts
|
|
371
|
+
});
|
|
372
|
+
return this;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Disables OpenAPI documentation
|
|
376
|
+
*
|
|
377
|
+
* @returns The builder instance for chaining
|
|
378
|
+
*/
|
|
379
|
+
disableOpenApi() {
|
|
380
|
+
return this.setEnabled("openApi", false);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Configures the server as a microservice with versioning.
|
|
384
|
+
*
|
|
385
|
+
* @param opts - Microservice configuration options including app name and service version
|
|
386
|
+
* @returns The builder instance for chaining
|
|
387
|
+
* @default - { isMicroservice: false, appName: 'express_app' }
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```typescript
|
|
391
|
+
* builder.withMicroService({
|
|
392
|
+
* appName: 'user-service',
|
|
393
|
+
* serviceVersion: {
|
|
394
|
+
* enable: true,
|
|
395
|
+
* version: '1.2.3'
|
|
396
|
+
* }
|
|
397
|
+
* })
|
|
398
|
+
* ```
|
|
399
|
+
*/
|
|
400
|
+
withMicroService(opts) {
|
|
401
|
+
this.config.isMicroservice = true;
|
|
402
|
+
this.config.appName = opts.appName;
|
|
403
|
+
this.mergeConfig("serviceVersion", opts.serviceVersion);
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Configures the trust proxy settings to determine if X-Forwarded-* headers should be trusted.
|
|
408
|
+
*
|
|
409
|
+
* @param opts - Trust proxy configuration options
|
|
410
|
+
* @returns The builder instance for chaining
|
|
411
|
+
* @default false
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```typescript
|
|
415
|
+
* // Trust proxy headers (useful when behind a load balancer)
|
|
416
|
+
* builder.withTrustProxy(true)
|
|
417
|
+
* ```
|
|
418
|
+
*/
|
|
419
|
+
withTrustProxy(opts) {
|
|
420
|
+
this.config.trustProxy = opts;
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Configures the request ID middleware for tracing requests across services.
|
|
425
|
+
*
|
|
426
|
+
* @param opts - Request ID configuration options
|
|
427
|
+
* @returns The builder instance for chaining
|
|
428
|
+
* @default - { headerName: 'x-request-id', exposeHeader: true }
|
|
429
|
+
*
|
|
430
|
+
* @example
|
|
431
|
+
* ```typescript
|
|
432
|
+
* builder.withRequestId({
|
|
433
|
+
* headerName: 'X-Request-Id',
|
|
434
|
+
* generator: () => crypto.randomUUID()
|
|
435
|
+
* })
|
|
436
|
+
* ```
|
|
437
|
+
*/
|
|
438
|
+
withRequestId(opts) {
|
|
439
|
+
this.mergeConfig("requestId", opts);
|
|
440
|
+
return this;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Configures the response time middleware for measuring request processing times.
|
|
444
|
+
*
|
|
445
|
+
* @param opts - Response time configuration options
|
|
446
|
+
* @returns The builder instance for chaining
|
|
447
|
+
* @default - { enable: false, addHeader: true, logOnComplete: false }
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* ```typescript
|
|
451
|
+
* builder.withResponseTime({
|
|
452
|
+
* enable: true,
|
|
453
|
+
* addHeader: true,
|
|
454
|
+
* logOnComplete: true
|
|
455
|
+
* })
|
|
456
|
+
* ```
|
|
457
|
+
*/
|
|
458
|
+
withResponseTime(opts) {
|
|
459
|
+
this.mergeConfig("responseTime", opts);
|
|
460
|
+
return this;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Enables response time tracking with default or custom settings
|
|
464
|
+
*
|
|
465
|
+
* @param opts - Optional response time configuration
|
|
466
|
+
* @returns The builder instance for chaining
|
|
467
|
+
*/
|
|
468
|
+
enableResponseTime(opts = {}) {
|
|
469
|
+
this.setEnabled("responseTime", true, opts);
|
|
470
|
+
return this;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Disables response time tracking
|
|
474
|
+
*
|
|
475
|
+
* @returns The builder instance for chaining
|
|
476
|
+
*/
|
|
477
|
+
disableResponseTime() {
|
|
478
|
+
return this.setEnabled("responseTime", false);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Configures the body parser middleware options for parsing request bodies.
|
|
482
|
+
*
|
|
483
|
+
* @param opts - Body parser configuration options
|
|
484
|
+
* @returns The builder instance for chaining
|
|
485
|
+
* @default - { json: { limit: '1mb' }, urlencoded: { extended: true, limit: '1mb' } }
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```typescript
|
|
489
|
+
* builder.withBodyParser({
|
|
490
|
+
* json: {
|
|
491
|
+
* limit: '1mb'
|
|
492
|
+
* },
|
|
493
|
+
* urlencoded: {
|
|
494
|
+
* extended: true,
|
|
495
|
+
* limit: '1mb'
|
|
496
|
+
* }
|
|
497
|
+
* })
|
|
498
|
+
* ```
|
|
499
|
+
*/
|
|
500
|
+
withBodyParser(opts) {
|
|
501
|
+
this.config.bodyParser = {
|
|
502
|
+
...this.config.bodyParser,
|
|
503
|
+
...opts
|
|
504
|
+
};
|
|
505
|
+
return this;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Configures cookie parsing middleware.
|
|
509
|
+
*
|
|
510
|
+
* @param opts - Cookie parser options or boolean (true to enable with defaults, false to disable)
|
|
511
|
+
* @returns The builder instance for chaining
|
|
512
|
+
* @default false
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```typescript
|
|
516
|
+
* // Enable cookie parsing with default options
|
|
517
|
+
* builder.withCookies(true)
|
|
518
|
+
*
|
|
519
|
+
* // Enable cookie parsing with specific options
|
|
520
|
+
* builder.withCookies({
|
|
521
|
+
* secret: 'your-secret-key',
|
|
522
|
+
* secure: true
|
|
523
|
+
* })
|
|
524
|
+
* ```
|
|
525
|
+
*/
|
|
526
|
+
withCookies(opts) {
|
|
527
|
+
this.config.cookieParser = opts;
|
|
528
|
+
return this;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Adds a static folder to serve files from.
|
|
532
|
+
*
|
|
533
|
+
* @param folder - Static folder configuration
|
|
534
|
+
* @returns The builder instance for chaining
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```typescript
|
|
538
|
+
* builder.withStaticFolder({
|
|
539
|
+
* path: '/assets',
|
|
540
|
+
* directory: './public',
|
|
541
|
+
* options: { maxAge: '1d' }
|
|
542
|
+
* })
|
|
543
|
+
* ```
|
|
544
|
+
*/
|
|
545
|
+
withStaticFolder(folder) {
|
|
546
|
+
if (!folder.path) throw new Error("Static folder requires a path");
|
|
547
|
+
const folders = [
|
|
548
|
+
...this.config.staticFolders ?? [],
|
|
549
|
+
folder
|
|
550
|
+
];
|
|
551
|
+
this.config.staticFolders = Array.from(new Map(folders.map((f) => [
|
|
552
|
+
f.path,
|
|
553
|
+
f
|
|
554
|
+
])).values());
|
|
555
|
+
return this;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Sets global headers to be included in all responses.
|
|
559
|
+
*
|
|
560
|
+
* @param headers - Object containing header name/value pairs or functions that return values
|
|
561
|
+
* @returns The builder instance for chaining
|
|
562
|
+
* @default - {}
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* ```typescript
|
|
566
|
+
* builder.withGlobalHeaders({
|
|
567
|
+
* 'X-Powered-By': 'Catbee',
|
|
568
|
+
* 'Server-Time': () => new Date().toISOString()
|
|
569
|
+
* })
|
|
570
|
+
* ```
|
|
571
|
+
*/
|
|
572
|
+
withGlobalHeaders(headers) {
|
|
573
|
+
this.mergeConfig("globalHeaders", headers);
|
|
574
|
+
return this;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Sets a global prefix for all routes.
|
|
578
|
+
*
|
|
579
|
+
* @param prefix - The prefix to prepend to all routes (e.g., '/api/v1')
|
|
580
|
+
* @returns The builder instance for chaining
|
|
581
|
+
* @default '/'
|
|
582
|
+
*
|
|
583
|
+
* @example
|
|
584
|
+
* ```typescript
|
|
585
|
+
* builder.withGlobalPrefix('/api/v1')
|
|
586
|
+
* ```
|
|
587
|
+
*/
|
|
588
|
+
withGlobalPrefix(prefix) {
|
|
589
|
+
this.config.globalPrefix = prefix;
|
|
590
|
+
return this;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Applies custom configuration overrides directly.
|
|
594
|
+
*
|
|
595
|
+
* @param overrides - Custom configuration options to merge
|
|
596
|
+
* @returns The builder instance for chaining
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```typescript
|
|
600
|
+
* builder.withCustom({
|
|
601
|
+
* port: 8080,
|
|
602
|
+
* customMiddleware: myMiddlewareFunction
|
|
603
|
+
* })
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
withCustom(overrides) {
|
|
607
|
+
this.config = deepObjMerge({}, this.config, overrides);
|
|
608
|
+
return this;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Configures HTTPS server options.
|
|
612
|
+
*
|
|
613
|
+
* @param opts - HTTPS configuration (key, cert, ca, passphrase, etc.)
|
|
614
|
+
* @returns The builder instance for chaining
|
|
615
|
+
*
|
|
616
|
+
* @example
|
|
617
|
+
* ```typescript
|
|
618
|
+
* builder.withHttps({
|
|
619
|
+
* key: './localhost-key.pem',
|
|
620
|
+
* cert: './localhost-cert.pem'
|
|
621
|
+
* })
|
|
622
|
+
* ```
|
|
623
|
+
*/
|
|
624
|
+
withHttps(opts) {
|
|
625
|
+
this.config.https = opts;
|
|
626
|
+
return this;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Builds and returns the final server configuration.
|
|
630
|
+
*
|
|
631
|
+
* This method merges the user-specified configuration with default values,
|
|
632
|
+
* ensures all sections with 'enable' flags are properly structured, and
|
|
633
|
+
* produces the final configuration to be used by the server.
|
|
634
|
+
*
|
|
635
|
+
* @returns The complete ServerConfig object
|
|
636
|
+
*
|
|
637
|
+
* @example
|
|
638
|
+
* ```typescript
|
|
639
|
+
* const config = new ServerConfigBuilder()
|
|
640
|
+
* .withPort(3000)
|
|
641
|
+
* .withHost('localhost')
|
|
642
|
+
* .withCors(true)
|
|
643
|
+
* .build();
|
|
644
|
+
* ```
|
|
645
|
+
*/
|
|
646
|
+
build() {
|
|
647
|
+
const config = deepObjMerge({}, getCatbeeServerGlobalConfig(), this.config);
|
|
648
|
+
if (config.openApi?.enable && !config.openApi.filePath) {
|
|
649
|
+
throw new Error("OpenAPI is enabled but no filePath is specified");
|
|
650
|
+
}
|
|
651
|
+
return Object.freeze({
|
|
652
|
+
...config,
|
|
653
|
+
[BUILD_MARKER]: true
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
mergeConfig(key, value) {
|
|
657
|
+
const current = this.config[key] && typeof this.config[key] === "object" ? deepClone(this.config[key]) : {};
|
|
658
|
+
this.config[key] = deepObjMerge({}, current, value);
|
|
659
|
+
}
|
|
660
|
+
setEnabled(key, enable, overrides = {}) {
|
|
661
|
+
this.mergeConfig(key, {
|
|
662
|
+
...overrides,
|
|
663
|
+
enable
|
|
664
|
+
});
|
|
665
|
+
return this;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
var getDependencyErrorMessage = /* @__PURE__ */ __name((packageName, context) => {
|
|
669
|
+
const ctxPart = context ? `for ${context}` : "";
|
|
670
|
+
const spacer = ctxPart ? ` ${ctxPart}` : "";
|
|
671
|
+
return `Missing required dependency${spacer}: ${packageName}. Please install it to proceed.`;
|
|
672
|
+
}, "getDependencyErrorMessage");
|
|
673
|
+
var DependencyErrors = {
|
|
674
|
+
express: getDependencyErrorMessage("express"),
|
|
675
|
+
helmet: getDependencyErrorMessage("helmet"),
|
|
676
|
+
cors: getDependencyErrorMessage("cors"),
|
|
677
|
+
compression: getDependencyErrorMessage("compression"),
|
|
678
|
+
"express-rate-limit": getDependencyErrorMessage("express-rate-limit"),
|
|
679
|
+
"cookie-parser": getDependencyErrorMessage("cookie-parser"),
|
|
680
|
+
"@scalar/express-api-reference": getDependencyErrorMessage("@scalar/express-api-reference"),
|
|
681
|
+
"prom-client": getDependencyErrorMessage("prom-client")
|
|
682
|
+
};
|
|
683
|
+
var ExpressServer = class {
|
|
684
|
+
static {
|
|
685
|
+
__name(this, "ExpressServer");
|
|
686
|
+
}
|
|
687
|
+
/** Prometheus client registry for metrics collection */
|
|
688
|
+
register = null;
|
|
689
|
+
/** HTTP server instance (null when not running) */
|
|
690
|
+
server = null;
|
|
691
|
+
/** Merged configuration with defaults applied */
|
|
692
|
+
config;
|
|
693
|
+
/** User-defined lifecycle hooks */
|
|
694
|
+
hooks;
|
|
695
|
+
/** Global API prefix (from config) */
|
|
696
|
+
globalPrefix;
|
|
697
|
+
/** Internal fallback router */
|
|
698
|
+
rootRouter;
|
|
699
|
+
/** User-supplied router */
|
|
700
|
+
externalRouter;
|
|
701
|
+
/** Internal Express app instance */
|
|
702
|
+
app;
|
|
703
|
+
/** Set of active WebSocket connections */
|
|
704
|
+
connections = /* @__PURE__ */ new Set();
|
|
705
|
+
/** Flag indicating if the server is shutting down */
|
|
706
|
+
isShuttingDown = false;
|
|
707
|
+
/**
|
|
708
|
+
* Collection of registered health check functions.
|
|
709
|
+
* These are executed when the health check endpoint is accessed.
|
|
710
|
+
*/
|
|
711
|
+
healthChecks = [];
|
|
712
|
+
/** Prometheus metrics for monitoring */
|
|
713
|
+
requestCounter;
|
|
714
|
+
routeTimings;
|
|
715
|
+
requestSizes;
|
|
716
|
+
clientIPs;
|
|
717
|
+
/** Promise that resolves when initialization (middleware + routes) is complete */
|
|
718
|
+
initPromise;
|
|
719
|
+
/**
|
|
720
|
+
* Initializes server with intelligent defaults and security best practices.
|
|
721
|
+
* All settings can be customized via config and hooks.
|
|
722
|
+
*
|
|
723
|
+
* Default Security:
|
|
724
|
+
* - Secure headers (Helmet)
|
|
725
|
+
* - Rate limiting
|
|
726
|
+
* - Request timeouts
|
|
727
|
+
* - Body size limits
|
|
728
|
+
* - CORS protection
|
|
729
|
+
*
|
|
730
|
+
* Default Monitoring:
|
|
731
|
+
* - Request/Response logging
|
|
732
|
+
* - Prometheus metrics
|
|
733
|
+
* - Health checks
|
|
734
|
+
* - Request tracing
|
|
735
|
+
*/
|
|
736
|
+
constructor(config, hooks = {}) {
|
|
737
|
+
if (this.hasBuildMarker(config)) {
|
|
738
|
+
this.config = config;
|
|
739
|
+
} else {
|
|
740
|
+
this.config = deepObjMerge({}, getCatbeeServerGlobalConfig(), config);
|
|
741
|
+
}
|
|
742
|
+
if (!isPort(this.config.port)) {
|
|
743
|
+
const msg = `Port must be a valid number between 1 and 65535, got: ${this.config.port}`;
|
|
744
|
+
getLogger().error(msg);
|
|
745
|
+
throw new Error(msg);
|
|
746
|
+
}
|
|
747
|
+
const safeAppName = (this.config.appName || "express_app").toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
748
|
+
if (this.config.metrics?.enable) {
|
|
749
|
+
const client = optionalRequire("prom-client");
|
|
750
|
+
if (!client) {
|
|
751
|
+
this.throwDependancyError("prom-client");
|
|
752
|
+
}
|
|
753
|
+
this.register = new client.Registry();
|
|
754
|
+
this.requestCounter = new client.Counter({
|
|
755
|
+
name: `${safeAppName}_http_requests_total`,
|
|
756
|
+
help: "Total HTTP requests",
|
|
757
|
+
labelNames: [
|
|
758
|
+
"method",
|
|
759
|
+
"route",
|
|
760
|
+
"status"
|
|
761
|
+
],
|
|
762
|
+
registers: [
|
|
763
|
+
this.register
|
|
764
|
+
]
|
|
765
|
+
});
|
|
766
|
+
this.routeTimings = new client.Histogram({
|
|
767
|
+
name: `${safeAppName}_http_request_duration_seconds`,
|
|
768
|
+
help: "Duration of HTTP requests by route",
|
|
769
|
+
labelNames: [
|
|
770
|
+
"method",
|
|
771
|
+
"route",
|
|
772
|
+
"status"
|
|
773
|
+
],
|
|
774
|
+
buckets: [
|
|
775
|
+
0.1,
|
|
776
|
+
0.3,
|
|
777
|
+
0.5,
|
|
778
|
+
0.7,
|
|
779
|
+
1,
|
|
780
|
+
3,
|
|
781
|
+
5,
|
|
782
|
+
7,
|
|
783
|
+
10
|
|
784
|
+
],
|
|
785
|
+
registers: [
|
|
786
|
+
this.register
|
|
787
|
+
]
|
|
788
|
+
});
|
|
789
|
+
this.requestSizes = new client.Histogram({
|
|
790
|
+
name: `${safeAppName}_http_request_size_bytes`,
|
|
791
|
+
help: "Size of HTTP request bodies",
|
|
792
|
+
labelNames: [
|
|
793
|
+
"method",
|
|
794
|
+
"route"
|
|
795
|
+
],
|
|
796
|
+
buckets: [
|
|
797
|
+
100,
|
|
798
|
+
1e3,
|
|
799
|
+
1e4,
|
|
800
|
+
1e5,
|
|
801
|
+
1e6
|
|
802
|
+
],
|
|
803
|
+
registers: [
|
|
804
|
+
this.register
|
|
805
|
+
]
|
|
806
|
+
});
|
|
807
|
+
this.clientIPs = new client.Counter({
|
|
808
|
+
name: `${safeAppName}_http_client_ip_total`,
|
|
809
|
+
help: "Client IP request counter",
|
|
810
|
+
labelNames: [
|
|
811
|
+
"ip",
|
|
812
|
+
"method"
|
|
813
|
+
],
|
|
814
|
+
registers: [
|
|
815
|
+
this.register
|
|
816
|
+
]
|
|
817
|
+
});
|
|
818
|
+
client.collectDefaultMetrics({
|
|
819
|
+
register: this.register,
|
|
820
|
+
prefix: `${safeAppName}_`
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (config?.healthCheck?.checks) {
|
|
824
|
+
this.healthChecks.push(...config.healthCheck.checks);
|
|
825
|
+
}
|
|
826
|
+
this.globalPrefix = this.normalizePath(this.config.globalPrefix ?? "", false);
|
|
827
|
+
this.hooks = hooks;
|
|
828
|
+
this.app = express();
|
|
829
|
+
this.rootRouter = express.Router();
|
|
830
|
+
this.initPromise = this.initialize();
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Execute a lifecycle hook safely with comprehensive error handling.
|
|
834
|
+
* Prevents hook failures from crashing the server while logging issues.
|
|
835
|
+
*
|
|
836
|
+
* @param hook Name of the lifecycle hook to execute
|
|
837
|
+
* @param args Arguments to pass to the hook function
|
|
838
|
+
*/
|
|
839
|
+
async runHook(hook, ...args) {
|
|
840
|
+
try {
|
|
841
|
+
const fn = this.hooks[hook];
|
|
842
|
+
if (fn) await fn(...args);
|
|
843
|
+
} catch (err) {
|
|
844
|
+
getLogger().error({
|
|
845
|
+
err,
|
|
846
|
+
hook
|
|
847
|
+
}, `Error executing ${hook} hook:`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Initialize the Express server with middleware and routes.
|
|
852
|
+
*/
|
|
853
|
+
async initialize() {
|
|
854
|
+
await this.runHook("beforeInit", this);
|
|
855
|
+
await this.setupMiddleware();
|
|
856
|
+
await this.setupRoutes();
|
|
857
|
+
await this.runHook("afterInit", this);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Configure and register all middlewares in the optimal order.
|
|
861
|
+
*
|
|
862
|
+
* Middleware Order (CRITICAL - don't change without understanding implications):
|
|
863
|
+
* 1. Basic server configuration (trust proxy, x-powered-by)
|
|
864
|
+
* 2. Request ID generation (for tracing)
|
|
865
|
+
* 3. Request context setup (for logging correlation)
|
|
866
|
+
* 4. Timeout protection (prevents hanging requests)
|
|
867
|
+
* 5. Response time tracking (for performance monitoring)
|
|
868
|
+
* 6. Request logging (after ID/context setup)
|
|
869
|
+
* 7. Custom request hooks
|
|
870
|
+
* 8. Security middleware (rate limiting, CORS, Helmet)
|
|
871
|
+
* 9. Response compression
|
|
872
|
+
* 10. Static file serving
|
|
873
|
+
* 11. Request parsing (body parsing, cookies)
|
|
874
|
+
* 12. API documentation (OpenAPI)
|
|
875
|
+
* 13. Global headers
|
|
876
|
+
* 14. Custom response hooks
|
|
877
|
+
*/
|
|
878
|
+
async setupMiddleware() {
|
|
879
|
+
if (this.config.https) {
|
|
880
|
+
await this.validateHttpsFiles();
|
|
881
|
+
}
|
|
882
|
+
this.app.disable("x-powered-by");
|
|
883
|
+
if (this.config.trustProxy) {
|
|
884
|
+
this.app.set("trust proxy", true);
|
|
885
|
+
}
|
|
886
|
+
this.app.use(requestId({
|
|
887
|
+
headerName: this.config.requestId?.headerName,
|
|
888
|
+
exposeHeader: this.config.requestId?.exposeHeader,
|
|
889
|
+
generator: this.config.requestId?.generator || uuid
|
|
890
|
+
}));
|
|
891
|
+
this.app.use(setupRequestContext({
|
|
892
|
+
headerName: this.config.requestId?.headerName,
|
|
893
|
+
autoLog: false
|
|
894
|
+
}));
|
|
895
|
+
this.app.use((_req, res, next) => {
|
|
896
|
+
if (this.isShuttingDown) {
|
|
897
|
+
res.setHeader("Connection", "close");
|
|
898
|
+
return res.status(HttpStatusCodes.SERVICE_UNAVAILABLE).json(new ServiceUnavailableException("Server is shutting down"));
|
|
899
|
+
}
|
|
900
|
+
next();
|
|
901
|
+
return;
|
|
902
|
+
});
|
|
903
|
+
if (this.config.helmet) {
|
|
904
|
+
const helmet = optionalRequire("helmet");
|
|
905
|
+
if (!helmet) {
|
|
906
|
+
this.throwDependancyError("helmet");
|
|
907
|
+
}
|
|
908
|
+
if (typeof this.config.helmet === "object") {
|
|
909
|
+
this.app.use(helmet(this.config.helmet));
|
|
910
|
+
} else {
|
|
911
|
+
this.app.use(helmet());
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (this.config.cors) {
|
|
915
|
+
const cors = optionalRequire("cors");
|
|
916
|
+
if (!cors) {
|
|
917
|
+
this.throwDependancyError("cors");
|
|
918
|
+
}
|
|
919
|
+
this.app.use(cors(this.config.cors === true ? {} : this.config.cors));
|
|
920
|
+
}
|
|
921
|
+
this.app.use((_req, res, next) => {
|
|
922
|
+
if (this.config.globalHeaders) {
|
|
923
|
+
for (const key in this.config.globalHeaders) {
|
|
924
|
+
const value = this.config.globalHeaders[key];
|
|
925
|
+
res.setHeader(key, typeof value === "function" ? value() : value);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (this.config.isMicroservice) {
|
|
929
|
+
res.setHeader("X-Microservice", this.config.appName || "express_app");
|
|
930
|
+
}
|
|
931
|
+
if (this.config.serviceVersion?.enable) {
|
|
932
|
+
const version = typeof this.config.serviceVersion?.version === "function" ? this.config.serviceVersion.version() : this.config.serviceVersion?.version;
|
|
933
|
+
res.setHeader(this.config.serviceVersion?.headerName || "x-service-version", version || "0.0.0");
|
|
934
|
+
}
|
|
935
|
+
next();
|
|
936
|
+
});
|
|
937
|
+
if (this.config.requestTimeout) {
|
|
938
|
+
this.app.use(timeout(this.config.requestTimeout));
|
|
939
|
+
}
|
|
940
|
+
if (this.config.responseTime?.enable) {
|
|
941
|
+
this.app.use(responseTime({
|
|
942
|
+
addHeader: this.config.responseTime.addHeader,
|
|
943
|
+
logOnComplete: this.config.responseTime.logOnComplete
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
if (this.config.rateLimit?.enable) {
|
|
947
|
+
const rateLimit = optionalRequire("express-rate-limit");
|
|
948
|
+
if (!rateLimit) {
|
|
949
|
+
this.throwDependancyError("express-rate-limit");
|
|
950
|
+
}
|
|
951
|
+
this.app.use(rateLimit({
|
|
952
|
+
windowMs: this.config.rateLimit.windowMs ?? 15 * 60 * 1e3,
|
|
953
|
+
max: this.config.rateLimit.max ?? 100,
|
|
954
|
+
handler: /* @__PURE__ */ __name((req, res) => {
|
|
955
|
+
const status = HttpStatusCodes.TOO_MANY_REQUESTS;
|
|
956
|
+
const response = createFinalErrorResponse(req, status, this.config.rateLimit?.message || "Too many requests");
|
|
957
|
+
res.status(status).json(response);
|
|
958
|
+
}, "handler"),
|
|
959
|
+
standardHeaders: this.config.rateLimit.standardHeaders ?? true,
|
|
960
|
+
legacyHeaders: this.config.rateLimit.legacyHeaders ?? false
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
if (this.config.requestLogging?.enable) {
|
|
964
|
+
this.app.use((req, res, next) => {
|
|
965
|
+
if (typeof this.config.requestLogging?.ignorePaths === "function") {
|
|
966
|
+
const skip = this.config.requestLogging?.ignorePaths?.(req, res);
|
|
967
|
+
if (skip) return next();
|
|
968
|
+
} else if (Array.isArray(this.config.requestLogging?.ignorePaths)) {
|
|
969
|
+
const skip = this.config.requestLogging?.ignorePaths?.some((path) => req.path.startsWith(path));
|
|
970
|
+
if (skip) return next();
|
|
971
|
+
}
|
|
972
|
+
const logger = getLogger();
|
|
973
|
+
const incomingRequestMetaData = {
|
|
974
|
+
requestId: req.id,
|
|
975
|
+
method: req.method,
|
|
976
|
+
url: req.originalUrl || req.url,
|
|
977
|
+
ip: req.ip
|
|
978
|
+
};
|
|
979
|
+
logger.info(incomingRequestMetaData, "Incoming Request");
|
|
980
|
+
next();
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
if (this.hooks.onRequest) {
|
|
984
|
+
this.app.use(this.hooks.onRequest);
|
|
985
|
+
}
|
|
986
|
+
if (this.config.compression) {
|
|
987
|
+
const compression = optionalRequire("compression");
|
|
988
|
+
if (!compression) {
|
|
989
|
+
this.throwDependancyError("compression");
|
|
990
|
+
}
|
|
991
|
+
if (typeof this.config.compression === "object") {
|
|
992
|
+
this.app.use(compression(this.config.compression));
|
|
993
|
+
} else {
|
|
994
|
+
this.app.use(compression());
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (this.config.staticFolders) {
|
|
998
|
+
this.config.staticFolders.forEach((folder) => {
|
|
999
|
+
this.app.use(this.normalizePath(folder.path ?? "/"), express.static(folder.directory, {
|
|
1000
|
+
maxAge: folder.maxAge || 0,
|
|
1001
|
+
etag: folder.etag !== false,
|
|
1002
|
+
immutable: folder.immutable === true,
|
|
1003
|
+
lastModified: folder.lastModified !== false,
|
|
1004
|
+
cacheControl: folder.cacheControl !== false
|
|
1005
|
+
}));
|
|
1006
|
+
getLogger().info(`Serving static folder: ${folder.directory} at path ${folder.path || "/"}`);
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
if (this.config.bodyParser) {
|
|
1010
|
+
if (this.config.bodyParser.json) {
|
|
1011
|
+
this.app.use(express.json(this.config.bodyParser.json));
|
|
1012
|
+
}
|
|
1013
|
+
if (this.config.bodyParser.urlencoded) {
|
|
1014
|
+
this.app.use(express.urlencoded(this.config.bodyParser.urlencoded));
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (this.config.cookieParser) {
|
|
1018
|
+
const cookieParser = optionalRequire("cookie-parser");
|
|
1019
|
+
if (!cookieParser) {
|
|
1020
|
+
this.throwDependancyError("cookie-parser");
|
|
1021
|
+
}
|
|
1022
|
+
if (typeof this.config.cookieParser === "object") {
|
|
1023
|
+
this.app.use(cookieParser(void 0, this.config.cookieParser));
|
|
1024
|
+
} else {
|
|
1025
|
+
this.app.use(cookieParser());
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (this.config.openApi?.enable) {
|
|
1029
|
+
try {
|
|
1030
|
+
const openApiMountPath = this.normalizePath(this.config.openApi.mountPath ?? "/docs", this.config.openApi.withGlobalPrefix);
|
|
1031
|
+
const openApiFilePath = this.config.openApi.filePath;
|
|
1032
|
+
if (!openApiFilePath) {
|
|
1033
|
+
const msg = "OpenAPI file path is required";
|
|
1034
|
+
getLogger().error(msg);
|
|
1035
|
+
throw new Error(msg);
|
|
1036
|
+
}
|
|
1037
|
+
const isOpenApiFilePathExists = await fileExists(openApiFilePath);
|
|
1038
|
+
if (!isOpenApiFilePathExists) {
|
|
1039
|
+
const msg = `OpenAPI spec file not found at ${openApiFilePath}`;
|
|
1040
|
+
getLogger().error(msg);
|
|
1041
|
+
throw new Error(msg);
|
|
1042
|
+
}
|
|
1043
|
+
if (this.config.openApi?.verbose) {
|
|
1044
|
+
getLogger().info(`Mounting OpenAPI docs at ${openApiMountPath}`);
|
|
1045
|
+
getLogger().info(`Using OpenAPI spec file at ${openApiFilePath}`);
|
|
1046
|
+
}
|
|
1047
|
+
const apiReference = optionalRequire("@scalar/express-api-reference")?.apiReference;
|
|
1048
|
+
if (!apiReference) {
|
|
1049
|
+
this.throwDependancyError("@scalar/express-api-reference", getDependencyErrorMessage("@scalar/express-api-reference", "OpenAPI docs"));
|
|
1050
|
+
}
|
|
1051
|
+
this.app.use(openApiMountPath, apiReference({
|
|
1052
|
+
spec: {
|
|
1053
|
+
content: await readFile(openApiFilePath, "utf8")
|
|
1054
|
+
}
|
|
1055
|
+
}));
|
|
1056
|
+
if (this.config.openApi?.verbose) {
|
|
1057
|
+
getLogger().info(`Mounted OpenAPI docs at ${openApiMountPath}`);
|
|
1058
|
+
}
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
getLogger().error({
|
|
1061
|
+
err
|
|
1062
|
+
}, "Failed to mount OpenAPI docs");
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (this.hooks.onResponse) {
|
|
1066
|
+
this.app.use(this.globalPrefix, this.hooks.onResponse);
|
|
1067
|
+
}
|
|
1068
|
+
if (this.config.metrics?.enable) {
|
|
1069
|
+
this.app.use((req, res, next) => {
|
|
1070
|
+
const start = process.hrtime();
|
|
1071
|
+
this.clientIPs?.inc({
|
|
1072
|
+
ip: req.ip,
|
|
1073
|
+
method: req.method
|
|
1074
|
+
});
|
|
1075
|
+
const cl = req.headers["content-length"];
|
|
1076
|
+
if (cl) {
|
|
1077
|
+
const size = Number(cl);
|
|
1078
|
+
if (!Number.isNaN(size) && size >= 0) {
|
|
1079
|
+
const route = this.normalizeRouteForMetrics(req, res);
|
|
1080
|
+
this.requestSizes?.observe({
|
|
1081
|
+
method: req.method,
|
|
1082
|
+
route
|
|
1083
|
+
}, size);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
res.once("finish", () => {
|
|
1087
|
+
const [seconds, nanoseconds] = process.hrtime(start);
|
|
1088
|
+
const finalRoute = this.normalizeRouteForMetrics(req, res);
|
|
1089
|
+
this.requestCounter?.inc({
|
|
1090
|
+
method: req.method,
|
|
1091
|
+
route: finalRoute,
|
|
1092
|
+
status: res.statusCode.toString()
|
|
1093
|
+
});
|
|
1094
|
+
this.routeTimings?.observe({
|
|
1095
|
+
method: req.method,
|
|
1096
|
+
route: finalRoute,
|
|
1097
|
+
status: res.statusCode.toString()
|
|
1098
|
+
}, seconds + nanoseconds / 1e9);
|
|
1099
|
+
});
|
|
1100
|
+
next();
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Configure server routes and error handling.
|
|
1106
|
+
* Sets up in following order:
|
|
1107
|
+
*
|
|
1108
|
+
* 1. Built-in routes (health, metrics)
|
|
1109
|
+
* 2. Application routes
|
|
1110
|
+
* 3. 404 handler
|
|
1111
|
+
* 4. Error handler
|
|
1112
|
+
*/
|
|
1113
|
+
async setupRoutes() {
|
|
1114
|
+
const healthCheckPath = this.normalizePath(this.config.healthCheck?.path || "/healthz", this.config.healthCheck?.withGlobalPrefix);
|
|
1115
|
+
this.app.get(healthCheckPath, async (_req, res) => {
|
|
1116
|
+
try {
|
|
1117
|
+
if (!this.healthChecks.length || getCatbeeServerGlobalConfig().skipHealthz) {
|
|
1118
|
+
return res.status(HttpStatusCodes.OK).json(new SuccessResponse("OK"));
|
|
1119
|
+
}
|
|
1120
|
+
const checkResults = await Promise.allSettled(this.healthChecks.map(async ({ name, check }) => {
|
|
1121
|
+
try {
|
|
1122
|
+
const status2 = await Promise.resolve(check());
|
|
1123
|
+
return {
|
|
1124
|
+
name,
|
|
1125
|
+
status: status2,
|
|
1126
|
+
error: null
|
|
1127
|
+
};
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
return {
|
|
1130
|
+
name,
|
|
1131
|
+
status: false,
|
|
1132
|
+
error: error.message
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
}));
|
|
1136
|
+
const results = checkResults.map((result) => {
|
|
1137
|
+
if (result.status === "fulfilled") return result.value;
|
|
1138
|
+
return {
|
|
1139
|
+
name: "unknown",
|
|
1140
|
+
status: false,
|
|
1141
|
+
error: result.reason
|
|
1142
|
+
};
|
|
1143
|
+
});
|
|
1144
|
+
const allOk = results.every((r) => r.status);
|
|
1145
|
+
const status = allOk ? HttpStatusCodes.OK : HttpStatusCodes.SERVICE_UNAVAILABLE;
|
|
1146
|
+
const response = new SuccessResponse(allOk ? "OK" : "Service unavailable");
|
|
1147
|
+
if (!allOk) response.error = true;
|
|
1148
|
+
if (this.config.healthCheck?.detailed) response.data = {
|
|
1149
|
+
checks: results
|
|
1150
|
+
};
|
|
1151
|
+
return res.status(status).json(response);
|
|
1152
|
+
} catch {
|
|
1153
|
+
return res.status(HttpStatusCodes.INTERNAL_SERVER_ERROR).json(new InternalServerErrorException("Health check failed"));
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
if (this.config.metrics?.enable) {
|
|
1157
|
+
const metricsPath = this.normalizePath(this.config.metrics.path ?? "/metrics", this.config.metrics?.withGlobalPrefix);
|
|
1158
|
+
this.app.get(metricsPath, async (_req, res) => {
|
|
1159
|
+
res.set("Content-Type", this.register.contentType);
|
|
1160
|
+
res.end(await this.register.metrics());
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
const routerToUse = this.externalRouter || this.rootRouter;
|
|
1164
|
+
this.app.use(this.globalPrefix, routerToUse);
|
|
1165
|
+
this.app.use((req, res) => {
|
|
1166
|
+
const status = HttpStatusCodes.NOT_FOUND;
|
|
1167
|
+
const response = createFinalErrorResponse(req, status, `Route ${req.method.toUpperCase()} ${req.path} not found`);
|
|
1168
|
+
res.status(status).json(response);
|
|
1169
|
+
});
|
|
1170
|
+
this.app.use((err, req, res, next) => {
|
|
1171
|
+
const isNotFoundError = err instanceof NotFoundException;
|
|
1172
|
+
const shouldSkipLogging = !this.hooks.onError && isNotFoundError && this.config.requestLogging?.enable && this.config.requestLogging.skipNotFoundRoutes === true;
|
|
1173
|
+
if (this.hooks.onError) {
|
|
1174
|
+
this.hooks.onError(err, req, res, next);
|
|
1175
|
+
} else {
|
|
1176
|
+
const errorHandlerMiddleware = errorHandler({
|
|
1177
|
+
logErrors: !shouldSkipLogging,
|
|
1178
|
+
includeDetails: Env.isDev()
|
|
1179
|
+
// Only show stack traces in development
|
|
1180
|
+
});
|
|
1181
|
+
errorHandlerMiddleware(err, req, res, next);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Register a new health check function for monitoring service dependencies.
|
|
1187
|
+
*
|
|
1188
|
+
* Health checks are executed when the health endpoint is accessed and
|
|
1189
|
+
* help determine if the service is ready to handle requests.
|
|
1190
|
+
*
|
|
1191
|
+
* Examples:
|
|
1192
|
+
* - Database connectivity
|
|
1193
|
+
* - External service availability
|
|
1194
|
+
* - File system access
|
|
1195
|
+
* - Memory/CPU usage checks
|
|
1196
|
+
*
|
|
1197
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1198
|
+
* @param check Function returning boolean or Promise<boolean> indicating health
|
|
1199
|
+
* @returns This instance for method chaining
|
|
1200
|
+
*/
|
|
1201
|
+
registerHealthCheck(name, check) {
|
|
1202
|
+
this.healthChecks.push({
|
|
1203
|
+
name,
|
|
1204
|
+
check
|
|
1205
|
+
});
|
|
1206
|
+
return this;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Run registered health checks and return whether the service is ready.
|
|
1210
|
+
* Useful for readiness probes in deployment tooling.
|
|
1211
|
+
*
|
|
1212
|
+
* @returns Promise resolving to `true` when all checks pass, otherwise `false`.
|
|
1213
|
+
*/
|
|
1214
|
+
async ready() {
|
|
1215
|
+
try {
|
|
1216
|
+
if (!this.healthChecks.length || getCatbeeServerGlobalConfig().skipHealthz) return true;
|
|
1217
|
+
const checkResults = await Promise.allSettled(this.healthChecks.map(async ({ name, check }) => {
|
|
1218
|
+
try {
|
|
1219
|
+
const status = await Promise.resolve(check());
|
|
1220
|
+
return {
|
|
1221
|
+
name,
|
|
1222
|
+
status,
|
|
1223
|
+
error: null
|
|
1224
|
+
};
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
return {
|
|
1227
|
+
name,
|
|
1228
|
+
status: false,
|
|
1229
|
+
error: error.message
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
}));
|
|
1233
|
+
const results = checkResults.map((result) => result.status === "fulfilled" ? result.value : {
|
|
1234
|
+
name: "unknown",
|
|
1235
|
+
status: false,
|
|
1236
|
+
error: result.reason
|
|
1237
|
+
});
|
|
1238
|
+
return results.every((r) => r.status === true);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
getLogger().error({
|
|
1241
|
+
err
|
|
1242
|
+
}, "Error while running readiness checks");
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Get the underlying Express application instance.
|
|
1248
|
+
* Use this for advanced Express features not exposed by this wrapper.
|
|
1249
|
+
*
|
|
1250
|
+
* @returns The raw Express app instance
|
|
1251
|
+
*/
|
|
1252
|
+
getApp() {
|
|
1253
|
+
return this.app;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Get the active HTTP/HTTPS server instance.
|
|
1257
|
+
* Returns null if the server is not currently running.
|
|
1258
|
+
*
|
|
1259
|
+
* @returns The HTTP/HTTPS server instance or null
|
|
1260
|
+
*/
|
|
1261
|
+
getServer() {
|
|
1262
|
+
return this.server;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Start the HTTP server and begin listening for requests.
|
|
1266
|
+
*
|
|
1267
|
+
* This method:
|
|
1268
|
+
* - Executes beforeStart hooks
|
|
1269
|
+
* - Binds to the configured host/port
|
|
1270
|
+
* - Sets up error handling for startup failures
|
|
1271
|
+
* - Executes afterStart hooks on success
|
|
1272
|
+
* - Logs startup information
|
|
1273
|
+
*
|
|
1274
|
+
* @returns Promise resolving to the running HTTP server instance
|
|
1275
|
+
* @throws Error if server fails to start or port is already in use
|
|
1276
|
+
*/
|
|
1277
|
+
async start() {
|
|
1278
|
+
await this.initPromise;
|
|
1279
|
+
await this.runHook("beforeStart", this.app);
|
|
1280
|
+
return new Promise((resolve, reject) => {
|
|
1281
|
+
try {
|
|
1282
|
+
const listenArgs = [
|
|
1283
|
+
this.config.port,
|
|
1284
|
+
this.config.host,
|
|
1285
|
+
async () => {
|
|
1286
|
+
const protocol = this.config.https ? "https" : "http";
|
|
1287
|
+
const url = `${protocol}://${this.config.host}:${this.config.port}`;
|
|
1288
|
+
getLogger().info(`Server running on ${url}`);
|
|
1289
|
+
if (this.config.healthCheck?.path) {
|
|
1290
|
+
getLogger().info(`Health check available at ${url}${this.normalizePath(this.config.healthCheck.path, this.config.healthCheck.withGlobalPrefix)}`);
|
|
1291
|
+
}
|
|
1292
|
+
if (this.config.metrics?.enable && this.config.metrics.path) {
|
|
1293
|
+
getLogger().info(`Metrics available at ${url}${this.normalizePath(this.config.metrics.path, this.config.metrics.withGlobalPrefix)}`);
|
|
1294
|
+
}
|
|
1295
|
+
if (this.config.openApi?.enable) {
|
|
1296
|
+
getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
|
|
1297
|
+
}
|
|
1298
|
+
if (this.server) await this.runHook("afterStart", this.server);
|
|
1299
|
+
resolve(this.server);
|
|
1300
|
+
}
|
|
1301
|
+
];
|
|
1302
|
+
if (this.config.https) {
|
|
1303
|
+
const httpsOptions = {
|
|
1304
|
+
...this.config.https,
|
|
1305
|
+
key: readFileSync(this.config.https.key),
|
|
1306
|
+
cert: readFileSync(this.config.https.cert)
|
|
1307
|
+
};
|
|
1308
|
+
if (this.config.https.ca) {
|
|
1309
|
+
httpsOptions.ca = readFileSync(this.config.https.ca);
|
|
1310
|
+
}
|
|
1311
|
+
if (this.config.https.passphrase) {
|
|
1312
|
+
httpsOptions.passphrase = this.config.https.passphrase;
|
|
1313
|
+
}
|
|
1314
|
+
this.server = https.createServer(httpsOptions, this.app).listen(...listenArgs);
|
|
1315
|
+
} else {
|
|
1316
|
+
this.server = this.app.listen(...listenArgs);
|
|
1317
|
+
}
|
|
1318
|
+
this.server.on("connection", (conn) => {
|
|
1319
|
+
this.connections.add(conn);
|
|
1320
|
+
conn.on("close", () => this.connections.delete(conn));
|
|
1321
|
+
});
|
|
1322
|
+
this.server.on("error", (err) => {
|
|
1323
|
+
getLogger().error({
|
|
1324
|
+
err
|
|
1325
|
+
}, "Server failed to start");
|
|
1326
|
+
reject(err);
|
|
1327
|
+
});
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
reject(error);
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Stop the HTTP server gracefully.
|
|
1335
|
+
*
|
|
1336
|
+
* This method:
|
|
1337
|
+
* - Executes beforeStop hooks
|
|
1338
|
+
* - Stops accepting new connections
|
|
1339
|
+
* - Waits for existing connections to finish
|
|
1340
|
+
* - Closes the server
|
|
1341
|
+
* - Executes afterStop hooks
|
|
1342
|
+
* - Logs shutdown information
|
|
1343
|
+
*
|
|
1344
|
+
* Graceful shutdown ensures:
|
|
1345
|
+
* - No requests are dropped
|
|
1346
|
+
* - Resources are properly cleaned up
|
|
1347
|
+
* - Monitoring systems are notified
|
|
1348
|
+
*/
|
|
1349
|
+
async stop(force = false) {
|
|
1350
|
+
if (!this.server) {
|
|
1351
|
+
getLogger().warn("Stop called but server is not running");
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (this.isShuttingDown) {
|
|
1355
|
+
getLogger().warn("Stop called while shutdown is already in progress");
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
this.isShuttingDown = true;
|
|
1359
|
+
await this.runHook("beforeStop", this.server);
|
|
1360
|
+
const shutdownTimeout = 1e4;
|
|
1361
|
+
const serverClosePromise = new Promise((resolve, reject) => {
|
|
1362
|
+
this.server.close(async (err) => {
|
|
1363
|
+
if (err) {
|
|
1364
|
+
getLogger().error({
|
|
1365
|
+
err
|
|
1366
|
+
}, "Error while closing server");
|
|
1367
|
+
reject(err);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
this.server = null;
|
|
1371
|
+
this.isShuttingDown = false;
|
|
1372
|
+
getLogger().info("Server stopped gracefully");
|
|
1373
|
+
await this.runHook("afterStop");
|
|
1374
|
+
resolve();
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Shutdown timeout")), shutdownTimeout));
|
|
1378
|
+
try {
|
|
1379
|
+
await Promise.race([
|
|
1380
|
+
serverClosePromise,
|
|
1381
|
+
timeoutPromise
|
|
1382
|
+
]);
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
getLogger().error({
|
|
1385
|
+
err
|
|
1386
|
+
}, "Graceful shutdown timed out");
|
|
1387
|
+
if (force) {
|
|
1388
|
+
getLogger().warn("Forcing connection destroy due to shutdown timeout");
|
|
1389
|
+
}
|
|
1390
|
+
} finally {
|
|
1391
|
+
await this.destroyConnections();
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Enable graceful shutdown on OS signals for production deployment.
|
|
1396
|
+
*
|
|
1397
|
+
* This is essential for:
|
|
1398
|
+
* - Container orchestration (Docker, Kubernetes)
|
|
1399
|
+
* - Process managers (PM2, systemd)
|
|
1400
|
+
* - Load balancer health checks
|
|
1401
|
+
* - Zero-downtime deployments
|
|
1402
|
+
*
|
|
1403
|
+
* @param signals Array of process signals to listen for (default: SIGINT, SIGTERM)
|
|
1404
|
+
*/
|
|
1405
|
+
enableGracefulShutdown(signals = [
|
|
1406
|
+
"SIGINT",
|
|
1407
|
+
"SIGTERM"
|
|
1408
|
+
]) {
|
|
1409
|
+
signals.forEach((signal) => {
|
|
1410
|
+
process.on(signal, async () => {
|
|
1411
|
+
getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
|
|
1412
|
+
try {
|
|
1413
|
+
await this.stop();
|
|
1414
|
+
process.exit(0);
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
getLogger().error({
|
|
1417
|
+
err
|
|
1418
|
+
}, "Error during graceful shutdown, forcing stop...");
|
|
1419
|
+
try {
|
|
1420
|
+
await this.stop(true);
|
|
1421
|
+
process.exit(1);
|
|
1422
|
+
} catch (forceError) {
|
|
1423
|
+
getLogger().fatal({
|
|
1424
|
+
forceError
|
|
1425
|
+
}, "Forced shutdown failed, exiting hard");
|
|
1426
|
+
process.exit(1);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
return this;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Set an externally created base router.
|
|
1435
|
+
* This will override the internal rootRouter.
|
|
1436
|
+
*/
|
|
1437
|
+
setBaseRouter(router) {
|
|
1438
|
+
this.externalRouter = router;
|
|
1439
|
+
return this;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Create and register a new router (only used if not injecting one externally - use `setBaseRouter` instead).
|
|
1443
|
+
*/
|
|
1444
|
+
createRouter(prefix = "") {
|
|
1445
|
+
const router = express.Router();
|
|
1446
|
+
const path = this.normalizePath(prefix, true);
|
|
1447
|
+
this.rootRouter.use(path, router);
|
|
1448
|
+
return router;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Register a new route handler with support for multiple HTTP methods.
|
|
1452
|
+
* The route is automatically registered under the globalPrefix if set.
|
|
1453
|
+
*
|
|
1454
|
+
* @param methods Array of HTTP methods (get, post, put, delete, etc.)
|
|
1455
|
+
* @param path Route path with Express path patterns support
|
|
1456
|
+
* @param handlers One or more Express request handlers (middleware + final handler)
|
|
1457
|
+
* @returns This instance for method chaining
|
|
1458
|
+
*/
|
|
1459
|
+
registerRoute(methods, path, ...handlers) {
|
|
1460
|
+
const fullPath = this.normalizePath(path, true);
|
|
1461
|
+
const methodMap = {
|
|
1462
|
+
get: this.app.get.bind(this.app),
|
|
1463
|
+
post: this.app.post.bind(this.app),
|
|
1464
|
+
put: this.app.put.bind(this.app),
|
|
1465
|
+
delete: this.app.delete.bind(this.app),
|
|
1466
|
+
patch: this.app.patch.bind(this.app),
|
|
1467
|
+
options: this.app.options.bind(this.app),
|
|
1468
|
+
head: this.app.head.bind(this.app)
|
|
1469
|
+
};
|
|
1470
|
+
methods.forEach((m) => {
|
|
1471
|
+
const fn = methodMap[m];
|
|
1472
|
+
if (fn) {
|
|
1473
|
+
fn(fullPath, ...handlers);
|
|
1474
|
+
} else {
|
|
1475
|
+
throw new Error(`Unsupported HTTP method: ${m}`);
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
return this;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Register custom middleware with optional path restriction.
|
|
1482
|
+
*
|
|
1483
|
+
* Use this for:
|
|
1484
|
+
* - Adding authentication to specific routes
|
|
1485
|
+
* - Custom logging or validation
|
|
1486
|
+
* - Request transformation
|
|
1487
|
+
* - Third-party middleware integration
|
|
1488
|
+
*
|
|
1489
|
+
* @param path Optional path prefix or middleware function if no path
|
|
1490
|
+
* @param middleware Middleware handler (required if path is provided)
|
|
1491
|
+
* @returns This instance for method chaining
|
|
1492
|
+
*/
|
|
1493
|
+
registerMiddleware(path, middleware) {
|
|
1494
|
+
if (typeof path === "string") {
|
|
1495
|
+
const normalizedPath = this.normalizePath(path);
|
|
1496
|
+
if (normalizedPath) {
|
|
1497
|
+
this.app.use(normalizedPath, middleware);
|
|
1498
|
+
} else {
|
|
1499
|
+
this.app.use(middleware);
|
|
1500
|
+
}
|
|
1501
|
+
} else {
|
|
1502
|
+
this.app.use(path);
|
|
1503
|
+
}
|
|
1504
|
+
return this;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Register one or more middleware functions to be applied globally.
|
|
1508
|
+
* This is a simpler alternative to registerMiddleware when you just want
|
|
1509
|
+
* to add middleware without path restrictions.
|
|
1510
|
+
*
|
|
1511
|
+
* @param middlewares One or more Express middleware functions
|
|
1512
|
+
* @returns This instance for method chaining
|
|
1513
|
+
*/
|
|
1514
|
+
useMiddleware(...middlewares) {
|
|
1515
|
+
middlewares.forEach((middleware) => {
|
|
1516
|
+
this.app.use(middleware);
|
|
1517
|
+
});
|
|
1518
|
+
return this;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Get Prometheus registry (to add custom counters/histograms)
|
|
1522
|
+
*
|
|
1523
|
+
* @return {*} {client.Registry}
|
|
1524
|
+
*/
|
|
1525
|
+
getMetricsRegistry() {
|
|
1526
|
+
if (!this.config.metrics?.enable) {
|
|
1527
|
+
getLogger().warn("Metrics are not enabled in the server configuration \nPlease enable metrics to use this feature.");
|
|
1528
|
+
}
|
|
1529
|
+
return this.register;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Get server configuration
|
|
1533
|
+
*
|
|
1534
|
+
* @return {*} {CatbeeServerConfig}
|
|
1535
|
+
*/
|
|
1536
|
+
getConfig() {
|
|
1537
|
+
return this.config;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Wait until server initialization (middleware + routes) has completed.
|
|
1541
|
+
* Useful for integration tests that inspect app before starting.
|
|
1542
|
+
*/
|
|
1543
|
+
async waitUntilReady() {
|
|
1544
|
+
await this.initPromise;
|
|
1545
|
+
}
|
|
1546
|
+
normalizePath(path, withGlobalPrefix = false) {
|
|
1547
|
+
const sanitize = /* @__PURE__ */ __name((p) => {
|
|
1548
|
+
return "/" + p.trim().replace(/^\/+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
1549
|
+
}, "sanitize");
|
|
1550
|
+
const prefix = withGlobalPrefix && this.globalPrefix ? sanitize(this.globalPrefix) : "";
|
|
1551
|
+
if (typeof path !== "string" || !path.trim()) {
|
|
1552
|
+
return prefix || "/";
|
|
1553
|
+
}
|
|
1554
|
+
return sanitize(prefix + "/" + path);
|
|
1555
|
+
}
|
|
1556
|
+
normalizeRouteForMetrics(req, res) {
|
|
1557
|
+
if (req?.route?.path) {
|
|
1558
|
+
return req.route.path;
|
|
1559
|
+
}
|
|
1560
|
+
if (res.statusCode === 404) return "/404";
|
|
1561
|
+
const path = (req.path || "unknown").split("?")[0];
|
|
1562
|
+
return path.replace(/\/[0-9]+/g, "/:id").replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid");
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Destroy all active connections (gracefully if possible).
|
|
1566
|
+
* If a connection does not close cleanly, it will be force-destroyed.
|
|
1567
|
+
*/
|
|
1568
|
+
async destroyConnections() {
|
|
1569
|
+
const total = this.connections.size;
|
|
1570
|
+
if (total === 0) {
|
|
1571
|
+
getLogger().debug("No active connections to close");
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const timeoutMs = 5e3;
|
|
1575
|
+
await Promise.race([
|
|
1576
|
+
Promise.all(Array.from(this.connections).map((conn) => new Promise((resolve) => {
|
|
1577
|
+
conn.end(() => {
|
|
1578
|
+
if (!conn.destroyed) conn.destroy();
|
|
1579
|
+
resolve();
|
|
1580
|
+
});
|
|
1581
|
+
conn.on("error", () => {
|
|
1582
|
+
conn.destroy();
|
|
1583
|
+
resolve();
|
|
1584
|
+
});
|
|
1585
|
+
}))),
|
|
1586
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
|
1587
|
+
]);
|
|
1588
|
+
this.connections.clear();
|
|
1589
|
+
getLogger().info(`Closed ${total} active connections`);
|
|
1590
|
+
}
|
|
1591
|
+
async validateHttpsFiles() {
|
|
1592
|
+
if (!await fileExists(this.config.https.key)) {
|
|
1593
|
+
const msg = `HTTPS key file not found: ${this.config.https.key}`;
|
|
1594
|
+
getLogger().error(msg);
|
|
1595
|
+
throw new Error(msg);
|
|
1596
|
+
}
|
|
1597
|
+
if (!await fileExists(this.config.https.cert)) {
|
|
1598
|
+
const msg = `HTTPS cert file not found: ${this.config.https.cert}`;
|
|
1599
|
+
getLogger().error(msg);
|
|
1600
|
+
throw new Error(msg);
|
|
1601
|
+
}
|
|
1602
|
+
if (this.config.https.ca && !await fileExists(this.config.https.ca)) {
|
|
1603
|
+
const msg = `HTTPS CA file not found: ${this.config.https.ca}`;
|
|
1604
|
+
getLogger().error(msg);
|
|
1605
|
+
throw new Error(msg);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
hasBuildMarker(config) {
|
|
1609
|
+
if (config?.[BUILD_MARKER]) {
|
|
1610
|
+
return true;
|
|
1611
|
+
}
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
throwDependancyError(packageName, msg) {
|
|
1615
|
+
getLogger().error({
|
|
1616
|
+
command: `npm install ${packageName}`
|
|
1617
|
+
}, msg || DependencyErrors[packageName]);
|
|
1618
|
+
throw new Error(msg || DependencyErrors[packageName]);
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
export { DependencyErrors, ExpressServer, ServerConfigBuilder };
|