Skip to content

Commit 3ea32fe

Browse files
committed
feat: add middleware utils
1 parent f2c56ab commit 3ea32fe

2 files changed

Lines changed: 422 additions & 0 deletions

File tree

src/utils/middleware.utils.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { randomUUID } from "crypto";
2+
import { Config } from "../config";
3+
import { uuid } from "./id.utils";
4+
import { HttpStatusCodes } from "./http-status-codes";
5+
import { ErrorResponse } from "./response.utils";
6+
import { Env } from "./env.utils";
7+
8+
/**
9+
* Type definitions for Express-compatible middleware
10+
*/
11+
export type Request = {
12+
headers: Record<string, string | string[] | undefined>;
13+
method: string;
14+
url: string;
15+
ip?: string;
16+
body?: any;
17+
query?: Record<string, any>;
18+
params?: Record<string, any>;
19+
[key: string]: any;
20+
};
21+
22+
export type Response = {
23+
status: (code: number) => Response;
24+
json: (data: any) => void;
25+
send: (data: any) => void;
26+
setHeader: (name: string, value: string | string[]) => void;
27+
end: (data?: any) => void;
28+
on: (event: string, callback: (...args: any[]) => void) => void;
29+
[key: string]: any;
30+
};
31+
32+
export type NextFunction = (err?: Error | any) => void;
33+
34+
export type Middleware = (
35+
req: Request,
36+
res: Response,
37+
next: NextFunction,
38+
) => void | Promise<void>;
39+
40+
/**
41+
* Attaches a unique request ID to each request.
42+
* Useful for request tracing and correlation between logs.
43+
*
44+
* @param {object} [options] - Configuration options
45+
* @param {string} [options.headerName='X-Request-ID'] - Header name for request ID
46+
* @param {boolean} [options.exposeHeader=true] - Whether to expose the header in response
47+
* @returns {Middleware} Express-compatible middleware
48+
*/
49+
export function requestId(options?: {
50+
headerName?: string;
51+
exposeHeader?: boolean;
52+
}): Middleware {
53+
const headerName = options?.headerName || "X-Request-ID";
54+
const exposeHeader = options?.exposeHeader !== false;
55+
56+
return (req, res, next) => {
57+
// Use existing request ID from header or generate a new one
58+
const existingId = req.headers[headerName.toLowerCase()];
59+
const id = (existingId as string) || randomUUID();
60+
61+
// Attach ID to request object
62+
req.id = id;
63+
64+
// Add ID to response headers
65+
if (exposeHeader) {
66+
res.setHeader(headerName, id);
67+
}
68+
69+
next();
70+
};
71+
}
72+
73+
/**
74+
* Measures request processing time and logs or adds it to response headers.
75+
*
76+
* @param {object} [options] - Configuration options
77+
* @param {boolean} [options.addHeader=true] - Whether to add X-Response-Time header
78+
* @param {boolean} [options.logOnComplete=false] - Whether to log timing info
79+
* @returns {Middleware} Express-compatible middleware
80+
*/
81+
export function responseTime(options?: {
82+
addHeader?: boolean;
83+
logOnComplete?: boolean;
84+
}): Middleware {
85+
const addHeader = options?.addHeader !== false;
86+
const logOnComplete = options?.logOnComplete === true;
87+
88+
return (req, res, next) => {
89+
const start = process.hrtime();
90+
91+
// Function to calculate elapsed time
92+
const calculateDuration = (): number => {
93+
const diff = process.hrtime(start);
94+
return diff[0] * 1e3 + diff[1] * 1e-6; // Convert to ms
95+
};
96+
97+
// Handle response completion
98+
res.on("finish", () => {
99+
const duration = calculateDuration();
100+
101+
if (addHeader) {
102+
res.setHeader("X-Response-Time", `${duration.toFixed(2)}ms`);
103+
}
104+
105+
if (logOnComplete) {
106+
const { method, url } = req;
107+
// eslint-disable-next-line no-console
108+
console.info(`${method} ${url} - ${duration.toFixed(2)}ms`);
109+
}
110+
});
111+
112+
next();
113+
};
114+
}
115+
116+
/**
117+
* Request timeout middleware.
118+
* Aborts requests that take too long to process.
119+
*
120+
* @param {number} [timeoutMs=30000] - Timeout in milliseconds
121+
* @returns {Middleware} Express-compatible middleware
122+
*/
123+
export function timeout(timeoutMs: number = Config.Http.timeout): Middleware {
124+
return (req, res, next) => {
125+
// Set timeout for the request
126+
const timer = setTimeout(() => {
127+
res.status(408).json({
128+
error: true,
129+
message: "Request timeout",
130+
status: 408,
131+
timestamp: new Date().toISOString(),
132+
});
133+
}, timeoutMs);
134+
135+
// Clear timeout when response is sent
136+
res.on("finish", () => {
137+
clearTimeout(timer);
138+
});
139+
140+
next();
141+
};
142+
}
143+
144+
/**
145+
* Creates a standardized error response object for API errors.
146+
*
147+
* @param {string} message - Error message
148+
* @param {Request} req - Express request object
149+
* @param {any} error - Original error object
150+
* @param {object} [options] - Additional options
151+
* @param {boolean} [options.includeDetails=false] - Whether to include error details in non-production
152+
* @returns {object} Formatted error response
153+
*/
154+
const createErrorResponse = (
155+
message: string,
156+
req: Request,
157+
error: any,
158+
options?: { includeDetails?: boolean },
159+
) => {
160+
const isDev = Env.isDev();
161+
const includeDetails = options?.includeDetails && isDev;
162+
163+
const response: Record<string, any> = {
164+
error: true,
165+
message,
166+
timestamp: new Date().toISOString(),
167+
requestId: error?.requestId || req.id || uuid(),
168+
path: req.originalUrl || req.url,
169+
};
170+
171+
// Include error code if present
172+
if (error?.code) {
173+
response.code = error.code;
174+
}
175+
176+
// Include stack trace in development mode if requested
177+
if (includeDetails && error?.stack) {
178+
response.stack = error.stack.split("\n").map((line: string) => line.trim());
179+
}
180+
181+
return response;
182+
};
183+
184+
/**
185+
* Global error handling middleware with enhanced features.
186+
*
187+
* @param {object} [options] - Error handler options
188+
* @param {boolean} [options.logErrors=true] - Whether to log errors
189+
* @param {boolean} [options.includeDetails=false] - Whether to include error details in non-production
190+
* @param {Function} [options.logger=console.error] - Custom logging function
191+
* @returns {(err: any, req: Request, res: Response, next: NextFunction) => void} Error middleware
192+
*/
193+
export function errorHandler(options?: {
194+
logErrors?: boolean;
195+
includeDetails?: boolean;
196+
logger?: (message: string, error: any) => void;
197+
}) {
198+
const logErrors = options?.logErrors !== false;
199+
const includeDetails = options?.includeDetails === true;
200+
// eslint-disable-next-line no-console
201+
const logger = options?.logger || console.error;
202+
203+
return (err: any, req: Request, res: Response, _next: NextFunction) => {
204+
// Determine status code
205+
const status =
206+
err?.status || err?.statusCode || HttpStatusCodes.INTERNAL_SERVER_ERROR;
207+
208+
// Log error if enabled
209+
if (logErrors) {
210+
const logMessage = `[ERROR] ${req.method} ${req.originalUrl || req.url}: ${err.message || "Unknown error"}`;
211+
logger(logMessage, err);
212+
}
213+
214+
// Handle ErrorResponse instances (our custom error class)
215+
if (err instanceof ErrorResponse) {
216+
return res.status(status).json({
217+
error: err.error,
218+
message: err.message,
219+
timestamp: err.timestamp,
220+
requestId: err.requestId || req.id || uuid(),
221+
path: req.originalUrl || req.url,
222+
});
223+
}
224+
225+
// Handle any other errors
226+
return res.status(status).json(
227+
createErrorResponse(err?.message || "Internal Server Error", req, err, {
228+
includeDetails,
229+
}),
230+
);
231+
};
232+
}

0 commit comments

Comments
 (0)