Skip to content

Commit 0bb15e4

Browse files
committed
feat(server): enhance port and hostname validation, add dynamic port handling
1 parent b47f6de commit 0bb15e4

6 files changed

Lines changed: 381 additions & 25 deletions

File tree

src/object/object.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ function cloneObject(obj: any, seen: WeakMap<object, any>): any {
424424
*/
425425
export function deepClone<T>(value: T): T {
426426
const seen = new WeakMap();
427-
return _deepClone(value, seen);
427+
return _deepClone(value, seen as WeakMap<object, any>);
428428
}
429429

430430
/**

src/server/server.builder.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { deepClone, deepObjMerge } from '@catbee/utils/object';
1+
import { deepClone, deepObjMerge, isPlainObject } from '@catbee/utils/object';
22
import type { CatbeeServerConfig } from '@catbee/utils/types';
33
import { getCatbeeServerGlobalConfig } from '@catbee/utils/config';
4-
import { isPort } from '@catbee/utils/validation';
4+
import { isPort, isHostname } from '@catbee/utils/validation';
55

66
export const BUILD_MARKER = Symbol.for('catbee.express.server.build');
77

@@ -29,18 +29,31 @@ export class ServerConfigBuilder {
2929
*
3030
* @private
3131
* @param port - The port number to validate
32-
* @throws {Error} If port is not an integer or is outside the valid range (1-65535)
32+
* @throws {Error} If port is not an integer or is outside the valid range (0-65535)
3333
*/
3434
private validatePort(port: number): void {
35-
if (!isPort(port)) {
36-
throw new Error(`Port must be a valid number between 1 and 65535, got: ${port}`);
35+
if (!isPort(port, true)) {
36+
throw new Error(`Port must be a valid number between 0 and 65535, got: ${port}`);
37+
}
38+
}
39+
40+
/**
41+
* Validates that a hostname is valid.
42+
*
43+
* @private
44+
* @param host - The hostname to validate
45+
* @throws {Error} If hostname is invalid
46+
*/
47+
private validateHost(host: string): void {
48+
if (!isHostname(host)) {
49+
throw new Error(`Host must be a valid hostname or IP address, got: ${host}`);
3750
}
3851
}
3952

4053
/**
4154
* Sets the port the server will listen on.
4255
*
43-
* @param port - The port number (1-65535)
56+
* @param port - The port number (0-65535). Use 0 for dynamic port assignment.
4457
* @returns The builder instance for chaining
4558
* @throws {Error} If port is invalid
4659
* @default 3000 (can be overridden via PORT env variable)
@@ -61,14 +74,17 @@ export class ServerConfigBuilder {
6174
*
6275
* @param host - The hostname (e.g., 'localhost', '0.0.0.0', '127.0.0.1')
6376
* @returns The builder instance for chaining
77+
* @throws {Error} If hostname is invalid
6478
* @default '0.0.0.0' (can be overridden via HOST env variable)
6579
*
6680
* @example
6781
* ```typescript
6882
* builder.withHost('0.0.0.0') // Listen on all interfaces
83+
* builder.withHost('localhost') // Listen on localhost only
6984
* ```
7085
*/
7186
withHost(host: string): this {
87+
this.validateHost(host);
7288
this.config.host = host;
7389
return this;
7490
}
@@ -512,13 +528,26 @@ export class ServerConfigBuilder {
512528
* ```
513529
*/
514530
withBodyParser(opts: NonNullable<CatbeeServerConfig['bodyParser']>): this {
531+
if (opts === true) {
532+
this.config.bodyParser = getCatbeeServerGlobalConfig().bodyParser;
533+
return this;
534+
} else if (opts === false) {
535+
this.config.bodyParser = false;
536+
return this;
537+
}
538+
515539
this.config.bodyParser = {
516-
...this.config.bodyParser,
540+
...(this.config.bodyParser as object),
517541
...opts
518542
};
519543
return this;
520544
}
521545

546+
disableBodyParser(): this {
547+
this.config.bodyParser = false;
548+
return this;
549+
}
550+
522551
/**
523552
* Configures cookie parsing middleware.
524553
*
@@ -676,7 +705,7 @@ export class ServerConfigBuilder {
676705
key: K,
677706
value: Partial<NonNullable<CatbeeServerConfig[K]>>
678707
): void {
679-
const current = this.config[key] && typeof this.config[key] === 'object' ? deepClone(this.config[key]!) : {};
708+
const current = isPlainObject(this.config[key]) ? deepClone(this.config[key]!) : {};
680709
this.config[key] = deepObjMerge({}, current, value) as NonNullable<CatbeeServerConfig[K]>;
681710
}
682711

src/server/server.ts

Lines changed: 162 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { Env } from '@catbee/utils/env';
99
import { getLogger } from '@catbee/utils/logger';
1010
import { InternalServerErrorException, ServiceUnavailableException, NotFoundException } from '@catbee/utils/exception';
1111
import { getCatbeeServerGlobalConfig } from '@catbee/utils/config';
12-
import { deepObjMerge } from '@catbee/utils/object';
12+
import { deepObjMerge, isPlainObject } from '@catbee/utils/object';
1313
import { fileExists, readFileSync, readFile } from '@catbee/utils/fs';
1414
import { CatbeeServerConfig, CatbeeServerHooks } from '@catbee/utils/types';
15-
import { isPort } from '@catbee/utils/validation';
15+
import { isPort, isHostname } from '@catbee/utils/validation';
1616
import { optionalRequire } from '@catbee/utils/async';
1717
import { BUILD_MARKER } from './server.builder';
1818
import { uuid } from '@catbee/utils/id';
@@ -109,14 +109,19 @@ export class ExpressServer {
109109
*/
110110
constructor(config: Partial<CatbeeServerConfig>, hooks: CatbeeServerHooks = {}) {
111111
if (this.hasBuildMarker(config)) {
112-
this.config = config as Required<CatbeeServerConfig>;
112+
this.config = deepObjMerge({}, config) as Required<CatbeeServerConfig>;
113113
} else {
114114
// Deep merge config with user overrides
115115
this.config = deepObjMerge({}, getCatbeeServerGlobalConfig(), config) as Required<CatbeeServerConfig>;
116116
}
117117

118-
if (!isPort(this.config.port)) {
119-
const msg = `Port must be a valid number between 1 and 65535, got: ${this.config.port}`;
118+
// Normalize host (strip brackets from IPv6 addresses for server.listen compatibility)
119+
if (this.config.host) {
120+
this.config.host = this.normalizeHost(this.config.host);
121+
}
122+
123+
if (!isPort(this.config.port, true)) {
124+
const msg = `Port must be a valid number between 0 and 65535, got: ${this.config.port}`;
120125
getLogger().error(msg);
121126
throw new Error(msg);
122127
}
@@ -415,7 +420,6 @@ export class ExpressServer {
415420

416421
const logger = getLogger();
417422
const incomingRequestMetaData = {
418-
requestId: req.id,
419423
method: req.method,
420424
url: req.originalUrl || req.url,
421425
ip: req.ip
@@ -474,13 +478,23 @@ export class ExpressServer {
474478
* Set up body parsing middleware.
475479
*/
476480
private setupBodyParsingMiddleware(): void {
477-
if (this.config.bodyParser) {
481+
if (isPlainObject(this.config.bodyParser)) {
478482
if (this.config.bodyParser.json) {
479483
this.app.use(express.json(this.config.bodyParser.json));
480484
}
481485
if (this.config.bodyParser.urlencoded) {
482486
this.app.use(express.urlencoded(this.config.bodyParser.urlencoded));
483487
}
488+
} else if (this.config.bodyParser === true) {
489+
const globalBodyParserConfig = getCatbeeServerGlobalConfig().bodyParser;
490+
if (isPlainObject(globalBodyParserConfig)) {
491+
if (globalBodyParserConfig.json) {
492+
this.app.use(express.json(globalBodyParserConfig.json));
493+
}
494+
if (globalBodyParserConfig.urlencoded) {
495+
this.app.use(express.urlencoded(globalBodyParserConfig.urlencoded));
496+
}
497+
}
484498
}
485499
}
486500

@@ -864,7 +878,9 @@ export class ExpressServer {
864878
*/
865879
private logServerStartInfo(): void {
866880
const protocol = this.config.https ? 'https' : 'http';
867-
const url = `${protocol}://${this.config.host}:${this.config.port}`;
881+
const port = this.getPort();
882+
const host = this.formatHostForUrl(this.config.host || 'localhost');
883+
const url = `${protocol}://${host}:${port}`;
868884
getLogger().info(`Server running on ${url}`);
869885

870886
if (this.config.healthCheck?.path) {
@@ -1112,6 +1128,118 @@ export class ExpressServer {
11121128
return this.config;
11131129
}
11141130

1131+
/**
1132+
* Get the port the server is listening on.
1133+
* Returns the actual port if server is running (useful when config.port was 0),
1134+
* otherwise returns the configured port.
1135+
*
1136+
* @returns The port number
1137+
*/
1138+
public getPort(): number {
1139+
const address = this.server?.address();
1140+
if (address && typeof address === 'object' && 'port' in address) {
1141+
return address.port;
1142+
}
1143+
return this.config.port;
1144+
}
1145+
1146+
/**
1147+
* Get the full URL the server is running on.
1148+
* Returns the actual URL if server is running (useful when config.port was 0),
1149+
* otherwise returns the configured URL.
1150+
*
1151+
* @returns The full server URL (e.g., "http://localhost:3000")
1152+
*/
1153+
public getUrl(): string {
1154+
const protocol = this.config.https ? 'https' : 'http';
1155+
const port = this.getPort();
1156+
const host = this.formatHostForUrl(this.config.host || 'localhost');
1157+
return `${protocol}://${host}:${port}`;
1158+
}
1159+
1160+
/**
1161+
* Check if the server is configured for dynamic port assignment.
1162+
* Returns true if the original port configuration was 0.
1163+
*
1164+
* @returns True if using dynamic port assignment, false otherwise
1165+
*/
1166+
public isPortDynamic(): boolean {
1167+
return this.config.port === 0;
1168+
}
1169+
1170+
/**
1171+
* Check if the server is currently running and listening for requests.
1172+
*
1173+
* @returns True if server is running, false otherwise
1174+
*/
1175+
public isRunning(): boolean {
1176+
return this.server !== null && this.server.listening;
1177+
}
1178+
1179+
/**
1180+
* Get the host address the server is bound to.
1181+
*
1182+
* @returns The host address
1183+
*/
1184+
public getHost(): string {
1185+
return this.config.host || 'localhost';
1186+
}
1187+
1188+
/**
1189+
* Get the protocol the server is using ('http' or 'https').
1190+
*
1191+
* @returns The protocol string
1192+
*/
1193+
public getProtocol(): string {
1194+
return this.config.https ? 'https' : 'http';
1195+
}
1196+
1197+
/**
1198+
* Check if the server is configured to use HTTPS.
1199+
*
1200+
* @returns True if using HTTPS, false otherwise
1201+
*/
1202+
public isHttps(): boolean {
1203+
return this.config.https !== undefined;
1204+
}
1205+
1206+
/**
1207+
* Set a new port for the server.
1208+
* Can only be called before the server starts listening.
1209+
* Useful for testing scenarios where you need to change the port dynamically.
1210+
*
1211+
* @param port - The new port number (0-65535)
1212+
* @throws Error if server is already running or port is invalid
1213+
*/
1214+
public setPort(port: number): void {
1215+
if (this.server) {
1216+
throw new Error('Cannot change port after server has started');
1217+
}
1218+
if (!isPort(port, true)) {
1219+
throw new Error(`Port must be a valid number between 0 and 65535, got: ${port}`);
1220+
}
1221+
this.config.port = port;
1222+
}
1223+
1224+
/**
1225+
* Set a new host for the server.
1226+
* Can only be called before the server starts listening.
1227+
* Useful for testing scenarios where you need to change the host dynamically.
1228+
*
1229+
* @param host - The new host (e.g., "localhost", "0.0.0.0", "127.0.0.1", "::1", or "[::1]")
1230+
* @throws {Error} If server is already running or host is invalid
1231+
*/
1232+
public setHost(host: string): void {
1233+
if (this.server) {
1234+
throw new Error('Cannot change host after server has started');
1235+
}
1236+
const normalizedHost = this.normalizeHost(host);
1237+
if (!isHostname(normalizedHost)) {
1238+
throw new Error(`Host must be a valid hostname or IP address, got: ${host}`);
1239+
}
1240+
this.config.host = normalizedHost;
1241+
}
1242+
11151243
/**
11161244
* Wait until server initialization (middleware + routes) has completed.
11171245
* Useful for integration tests that inspect app before starting.
@@ -1120,6 +1248,32 @@ export class ExpressServer {
11201248
await this.initPromise;
11211249
}
11221250

1251+
/**
1252+
* Normalize host by stripping surrounding brackets from IPv6 addresses.
1253+
* This ensures the host value is compatible with server.listen().
1254+
* Brackets are URL syntax only and must be removed for Node.js binding.
1255+
*/
1256+
private normalizeHost(host: string): string {
1257+
// Strip surrounding brackets from IPv6 addresses (e.g., "[::1]" -> "::1")
1258+
if (host.startsWith('[') && host.endsWith(']')) {
1259+
return host.slice(1, -1);
1260+
}
1261+
return host;
1262+
}
1263+
1264+
/**
1265+
* Format host for use in URLs.
1266+
* Wraps IPv6 addresses in brackets per RFC 3986.
1267+
*/
1268+
private formatHostForUrl(host: string): string {
1269+
// IPv6 addresses contain colons and need to be wrapped in brackets
1270+
// Since we normalize at input (strip brackets), we never have pre-bracketed hosts
1271+
if (host.includes(':')) {
1272+
return `[${host}]`;
1273+
}
1274+
return host;
1275+
}
1276+
11231277
private normalizePath(path: string, withGlobalPrefix = false): string {
11241278
const sanitize = (p: string): string => {
11251279
return (

src/types/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface CatbeeServerConfig {
1717
/** Server port
1818
* - **default**: `3000`
1919
* - **env**: `SERVER_PORT` || `PORT`
20+
* - Use `0` for dynamic port assignment (OS assigns available port)
2021
*/
2122
port: number;
2223

@@ -82,7 +83,7 @@ export interface CatbeeServerConfig {
8283
* - `SERVER_BODY_PARSER_JSON_LIMIT`
8384
* - `SERVER_BODY_PARSER_URLENCODED_LIMIT`
8485
*/
85-
bodyParser?: {
86+
bodyParser?: ToggleConfig<{
8687
/** JSON body parser options
8788
* - **default**: `{ limit: '1mb' }`
8889
*/
@@ -92,7 +93,7 @@ export interface CatbeeServerConfig {
9293
* - **default**: `{ extended: true, limit: '1mb' }`
9394
*/
9495
urlencoded?: Parameters<typeof urlencoded>[0];
95-
};
96+
}>;
9697

9798
/** Cookie parser toggle or options
9899
* - **default**: `false`

0 commit comments

Comments
 (0)