express-performance-toolkit 1.0.0 → 2.0.0
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/README.md +119 -76
- package/dashboard-ui/README.md +73 -0
- package/dashboard-ui/eslint.config.js +23 -0
- package/dashboard-ui/index.html +13 -0
- package/dashboard-ui/package-lock.json +3382 -0
- package/dashboard-ui/package.json +32 -0
- package/dashboard-ui/src/App.css +184 -0
- package/dashboard-ui/src/App.tsx +182 -0
- package/dashboard-ui/src/components/BlockedModal.tsx +108 -0
- package/dashboard-ui/src/components/CachePanel.tsx +45 -0
- package/dashboard-ui/src/components/HealthCharts.tsx +142 -0
- package/dashboard-ui/src/components/InsightsPanel.tsx +49 -0
- package/dashboard-ui/src/components/KpiGrid.tsx +178 -0
- package/dashboard-ui/src/components/LiveLogs.tsx +76 -0
- package/dashboard-ui/src/components/Login.tsx +83 -0
- package/dashboard-ui/src/components/RoutesTable.tsx +110 -0
- package/dashboard-ui/src/hooks/useMetrics.ts +131 -0
- package/dashboard-ui/src/index.css +652 -0
- package/dashboard-ui/src/main.tsx +10 -0
- package/dashboard-ui/src/pages/InsightsPage.tsx +42 -0
- package/dashboard-ui/src/pages/LogsPage.tsx +26 -0
- package/dashboard-ui/src/pages/OverviewPage.tsx +32 -0
- package/dashboard-ui/src/pages/RoutesPage.tsx +26 -0
- package/dashboard-ui/src/utils/formatters.ts +27 -0
- package/dashboard-ui/tsconfig.app.json +28 -0
- package/dashboard-ui/tsconfig.json +7 -0
- package/dashboard-ui/tsconfig.node.json +26 -0
- package/dashboard-ui/vite.config.ts +12 -0
- package/dist/analyzer.d.ts +6 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +70 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/dashboard/dashboardRouter.d.ts +4 -4
- package/dist/dashboard/dashboardRouter.d.ts.map +1 -1
- package/dist/dashboard/dashboardRouter.js +67 -21
- package/dist/dashboard/dashboardRouter.js.map +1 -1
- package/dist/dashboard-ui/assets/index-CX-zE-Qy.css +1 -0
- package/dist/dashboard-ui/assets/index-Q9TGkd8n.js +41 -0
- package/dist/dashboard-ui/index.html +14 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -11
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +3 -3
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +167 -9
- package/dist/logger.js.map +1 -1
- package/dist/queryHelper.d.ts.map +1 -1
- package/dist/queryHelper.js +1 -0
- package/dist/queryHelper.js.map +1 -1
- package/dist/rateLimit.d.ts +5 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +67 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +147 -25
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -1
- package/example/server.ts +68 -37
- package/package.json +9 -6
- package/src/analyzer.ts +78 -0
- package/src/dashboard/dashboardRouter.ts +88 -23
- package/src/index.ts +70 -30
- package/src/logger.ts +177 -13
- package/src/queryHelper.ts +2 -0
- package/src/rateLimit.ts +86 -0
- package/src/store.ts +136 -27
- package/src/types.ts +98 -0
- package/tests/analyzer.test.ts +108 -0
- package/tests/auth.test.ts +79 -0
- package/tests/bandwidth.test.ts +72 -0
- package/tests/integration.test.ts +51 -54
- package/tests/rateLimit.test.ts +57 -0
- package/tests/store.test.ts +37 -18
- package/tsconfig.json +1 -0
- package/src/dashboard/dashboard.html +0 -756
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "express-performance-toolkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "⚡ A powerful Express middleware that automatically optimizes your app with request caching, response compression, slow API detection, and a real-time performance dashboard.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"build": "
|
|
8
|
+
"build:ui": "cd dashboard-ui && npm install && npm run build",
|
|
9
|
+
"build": "tsc && npm run build:ui",
|
|
9
10
|
"postbuild": "npm run copy-assets",
|
|
10
|
-
"copy-assets": "
|
|
11
|
+
"copy-assets": "rm -rf dist/dashboard-ui && cp -R dashboard-ui/dist dist/dashboard-ui",
|
|
11
12
|
"dev": "tsc --watch",
|
|
12
13
|
"test": "jest --verbose",
|
|
13
|
-
"example": "ts-node example/server.ts",
|
|
14
|
+
"example": "npm run build && ts-node example/server.ts",
|
|
14
15
|
"prepublishOnly": "npm run build"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|
|
@@ -26,11 +27,13 @@
|
|
|
26
27
|
"logging",
|
|
27
28
|
"typescript"
|
|
28
29
|
],
|
|
29
|
-
"author": "",
|
|
30
|
+
"author": "Mayank Saini",
|
|
30
31
|
"license": "MIT",
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"compression": "^1.7.4",
|
|
33
|
-
"
|
|
34
|
+
"lucide-react": "^0.577.0",
|
|
35
|
+
"on-finished": "^2.4.1",
|
|
36
|
+
"recharts": "^3.8.0"
|
|
34
37
|
},
|
|
35
38
|
"peerDependencies": {
|
|
36
39
|
"express": ">=4.0.0"
|
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Metrics, Insight } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Heuristic-based analysis engine that transforms raw metrics into actionable advice.
|
|
5
|
+
*/
|
|
6
|
+
export function analyzeMetrics(metrics: Metrics): Insight[] {
|
|
7
|
+
const insights: Insight[] = [];
|
|
8
|
+
|
|
9
|
+
// 1. Caching Suggestions
|
|
10
|
+
Object.entries(metrics.routes).forEach(([path, stats]) => {
|
|
11
|
+
// If a route is slow and has low/zero cache hits
|
|
12
|
+
if (stats.avgTime > 500 && stats.count > 5) {
|
|
13
|
+
if (metrics.cacheHitRate < 10) {
|
|
14
|
+
insights.push({
|
|
15
|
+
type: "warning",
|
|
16
|
+
title: "Slow Route: Caching Opportunity",
|
|
17
|
+
message: `Route "${path}" is slow (avg ${stats.avgTime}ms).`,
|
|
18
|
+
action: "Consider enabling the cache middleware for this route.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. N+1 Query Detection
|
|
24
|
+
if (stats.highQueryCount > stats.count * 0.3) {
|
|
25
|
+
insights.push({
|
|
26
|
+
type: "error",
|
|
27
|
+
title: "Potential N+1 Query Detected",
|
|
28
|
+
message: `Route "${path}" triggers high query counts in ${Math.round((stats.highQueryCount / stats.count) * 100)}% of calls.`,
|
|
29
|
+
action: "Review your database logic and use eager loading (JOINs).",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Payload Size Alerts
|
|
34
|
+
if (stats.avgSize > 1024 * 1024) {
|
|
35
|
+
// > 1MB
|
|
36
|
+
insights.push({
|
|
37
|
+
type: "warning",
|
|
38
|
+
title: "Heavy Payload Detected",
|
|
39
|
+
message: `Route "${path}" sends large responses (avg ${(stats.avgSize / 1024 / 1024).toFixed(1)} MB).`,
|
|
40
|
+
action:
|
|
41
|
+
"Consider pagination or reducing the number of returned fields.",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 4. Rate Limiting Insights
|
|
46
|
+
if (stats.rateLimitHits > stats.count * 0.1) {
|
|
47
|
+
insights.push({
|
|
48
|
+
type: "info",
|
|
49
|
+
title: "High Rate Limiting Activity",
|
|
50
|
+
message: `Route "${path}" has a high block rate (${Math.round((stats.rateLimitHits / stats.count) * 100)}%).`,
|
|
51
|
+
action:
|
|
52
|
+
"Verify if your Rate Limit 'max' is too low or if an IP is scraping you.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 5. System Health
|
|
58
|
+
if (metrics.eventLoopLag > 100) {
|
|
59
|
+
insights.push({
|
|
60
|
+
type: "error",
|
|
61
|
+
title: "Event Loop Lagging",
|
|
62
|
+
message: `Critical event loop delay detected (${metrics.eventLoopLag}ms).`,
|
|
63
|
+
action: "Identify blocking synchronous operations in your code.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (metrics.memoryUsage.heapUsed > metrics.memoryUsage.heapLimit * 0.8) {
|
|
68
|
+
insights.push({
|
|
69
|
+
type: "warning",
|
|
70
|
+
title: "High Memory Pressure",
|
|
71
|
+
message: "Node.js heap usage is above 80% of total limit.",
|
|
72
|
+
action:
|
|
73
|
+
"Monitor for memory leaks or increase memory limit (--max-old-space-size).",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return insights;
|
|
78
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { Router, Request, Response } from
|
|
2
|
-
import * as path from
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
|
|
1
|
+
import express, { Router, Request, Response, NextFunction } from "express";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { MetricsStore } from "../store";
|
|
4
|
+
import { DashboardOptions } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Simple session-less auth token based on the secret.
|
|
8
|
+
* In a real-world scenario, you'd use a more robust session manager or JWT.
|
|
9
|
+
*/
|
|
10
|
+
const generateToken = (secret: string) => {
|
|
11
|
+
return Buffer.from(`auth:${secret}`).toString("base64");
|
|
12
|
+
};
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Create the dashboard Express router.
|
|
@@ -10,36 +17,94 @@ import { DashboardOptions } from '../types';
|
|
|
10
17
|
*/
|
|
11
18
|
export function createDashboardRouter(
|
|
12
19
|
store: MetricsStore,
|
|
13
|
-
|
|
20
|
+
options: DashboardOptions = {},
|
|
14
21
|
): Router {
|
|
15
22
|
const router = Router();
|
|
16
23
|
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
|
|
24
|
+
// Default auth settings if none provided (Security by default)
|
|
25
|
+
const auth = options.auth || {
|
|
26
|
+
username: "admin",
|
|
27
|
+
password: "perf-toolkit",
|
|
28
|
+
secret: "toolkit-secret",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const mountPath = options.path || "/__perf";
|
|
32
|
+
|
|
33
|
+
// Use JSON parsing for login endpoint
|
|
34
|
+
router.use(express.json());
|
|
35
|
+
|
|
36
|
+
// Authentication Middleware
|
|
37
|
+
const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
|
38
|
+
if (!auth) return next();
|
|
39
|
+
|
|
40
|
+
const expectedToken = generateToken(auth.secret || "toolkit-secret");
|
|
41
|
+
const cookie = req.headers.cookie || "";
|
|
42
|
+
const hasToken = cookie.includes(`perf-auth=${expectedToken}`);
|
|
43
|
+
|
|
44
|
+
if (hasToken) {
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
res.status(401).json({ success: false, message: "Unauthorized" });
|
|
49
|
+
};
|
|
20
50
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
51
|
+
// Login Endpoint
|
|
52
|
+
router.post("/api/login", (req: Request, res: Response) => {
|
|
53
|
+
if (!auth) {
|
|
54
|
+
return res.json({ success: true, message: "Auth disabled" });
|
|
55
|
+
}
|
|
26
56
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
57
|
+
const { username, password } = req.body;
|
|
58
|
+
const defaultUser = auth.username || "admin";
|
|
59
|
+
const defaultPass = auth.password || "perf-toolkit";
|
|
60
|
+
|
|
61
|
+
if (username === defaultUser && password === defaultPass) {
|
|
62
|
+
const token = generateToken(auth.secret || "toolkit-secret");
|
|
63
|
+
// Set cookie with HttpOnly and reasonable maxAge
|
|
64
|
+
res.setHeader(
|
|
65
|
+
"Set-Cookie",
|
|
66
|
+
`perf-auth=${token}; Path=${mountPath}; HttpOnly; Max-Age=86400; SameSite=Lax`,
|
|
67
|
+
);
|
|
68
|
+
return res.json({ success: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
res.status(401).json({ success: false, message: "Invalid credentials" });
|
|
31
72
|
});
|
|
32
73
|
|
|
33
|
-
//
|
|
34
|
-
router.
|
|
74
|
+
// Logout Endpoint
|
|
75
|
+
router.post("/api/logout", (_req: Request, res: Response) => {
|
|
76
|
+
res.setHeader(
|
|
77
|
+
"Set-Cookie",
|
|
78
|
+
`perf-auth=; Path=${mountPath}; HttpOnly; Max-Age=0`,
|
|
79
|
+
);
|
|
80
|
+
res.json({ success: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Check auth status endpoint
|
|
84
|
+
router.get("/api/auth-check", (req: Request, res: Response) => {
|
|
85
|
+
if (!auth) return res.json({ authenticated: true, required: false });
|
|
86
|
+
|
|
87
|
+
const expectedToken = generateToken(auth.secret || "toolkit-secret");
|
|
88
|
+
const cookie = req.headers.cookie || "";
|
|
89
|
+
const authenticated = cookie.includes(`perf-auth=${expectedToken}`);
|
|
90
|
+
|
|
91
|
+
res.json({ authenticated, required: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// JSON metrics API endpoint (Protected)
|
|
95
|
+
router.get("/api/metrics", requireAuth, (_req: Request, res: Response) => {
|
|
35
96
|
res.json(store.getMetrics());
|
|
36
97
|
});
|
|
37
98
|
|
|
38
|
-
// Reset metrics endpoint
|
|
39
|
-
router.post(
|
|
99
|
+
// Reset metrics endpoint (Protected)
|
|
100
|
+
router.post("/api/reset", requireAuth, (_req: Request, res: Response) => {
|
|
40
101
|
store.reset();
|
|
41
|
-
res.json({ success: true, message:
|
|
102
|
+
res.json({ success: true, message: "Metrics reset" });
|
|
42
103
|
});
|
|
43
104
|
|
|
105
|
+
// Serve React Dashboard UI bundle
|
|
106
|
+
const uiPath = path.resolve(__dirname, "../../dist/dashboard-ui");
|
|
107
|
+
router.use("/", express.static(uiPath));
|
|
108
|
+
|
|
44
109
|
return router;
|
|
45
110
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import { Request, Response, NextFunction, Router } from
|
|
2
|
-
import { MetricsStore } from
|
|
3
|
-
import { createCacheMiddleware, LRUCache } from
|
|
4
|
-
import { createCompressionMiddleware } from
|
|
5
|
-
import { createLoggerMiddleware } from
|
|
6
|
-
import { createQueryHelperMiddleware } from
|
|
7
|
-
import {
|
|
1
|
+
import { Request, Response, NextFunction, Router } from "express";
|
|
2
|
+
import { MetricsStore } from "./store";
|
|
3
|
+
import { createCacheMiddleware, LRUCache } from "./cache";
|
|
4
|
+
import { createCompressionMiddleware } from "./compression";
|
|
5
|
+
import { createLoggerMiddleware } from "./logger";
|
|
6
|
+
import { createQueryHelperMiddleware } from "./queryHelper";
|
|
7
|
+
import { createRateLimiter } from "./rateLimit";
|
|
8
|
+
import { createDashboardRouter } from "./dashboard/dashboardRouter";
|
|
8
9
|
import {
|
|
9
10
|
ToolkitOptions,
|
|
10
11
|
CacheOptions,
|
|
11
12
|
CompressionOptions,
|
|
12
13
|
LoggerOptions,
|
|
13
14
|
QueryHelperOptions,
|
|
15
|
+
RateLimitOptions,
|
|
14
16
|
DashboardOptions,
|
|
15
17
|
Metrics,
|
|
16
18
|
CacheMiddleware,
|
|
17
|
-
} from
|
|
19
|
+
} from "./types";
|
|
18
20
|
|
|
19
21
|
export interface ToolkitInstance {
|
|
20
22
|
/** The composed Express middleware */
|
|
@@ -50,44 +52,83 @@ export interface ToolkitInstance {
|
|
|
50
52
|
* app.use('/__perf', toolkit.dashboardRouter);
|
|
51
53
|
* ```
|
|
52
54
|
*/
|
|
53
|
-
export function performanceToolkit(
|
|
55
|
+
export function performanceToolkit(
|
|
56
|
+
options: ToolkitOptions = {},
|
|
57
|
+
): ToolkitInstance {
|
|
54
58
|
const store = new MetricsStore({ maxLogs: options.maxLogs || 1000 });
|
|
55
59
|
|
|
56
|
-
const middlewares: ((
|
|
60
|
+
const middlewares: ((
|
|
61
|
+
req: Request,
|
|
62
|
+
res: Response,
|
|
63
|
+
next: NextFunction,
|
|
64
|
+
) => void)[] = [];
|
|
57
65
|
let cacheMiddlewareInstance: CacheMiddleware | null = null;
|
|
58
66
|
|
|
67
|
+
// ── Dashboard Config ─────────────────────────────────────
|
|
68
|
+
const dashboardConfig = normalizeOption<DashboardOptions>(options.dashboard, {
|
|
69
|
+
enabled: true,
|
|
70
|
+
});
|
|
71
|
+
const dashboardExcludePath = dashboardConfig.path || "/__perf";
|
|
72
|
+
|
|
73
|
+
// ── Rate Limiter ─────────────────────────────────────────
|
|
74
|
+
const rateLimitConfig = normalizeOption<RateLimitOptions>(options.rateLimit, {
|
|
75
|
+
enabled: false,
|
|
76
|
+
});
|
|
77
|
+
if (rateLimitConfig.enabled !== false) {
|
|
78
|
+
// Automatically exclude dashboard from rate limiting to prevent UI lockouts
|
|
79
|
+
rateLimitConfig.exclude = [
|
|
80
|
+
...(rateLimitConfig.exclude || []),
|
|
81
|
+
dashboardExcludePath,
|
|
82
|
+
];
|
|
83
|
+
middlewares.push(createRateLimiter(store, rateLimitConfig));
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
// ── Compression ──────────────────────────────────────────
|
|
60
|
-
const compressionConfig = normalizeOption<CompressionOptions>(
|
|
87
|
+
const compressionConfig = normalizeOption<CompressionOptions>(
|
|
88
|
+
options.compression,
|
|
89
|
+
{ enabled: true },
|
|
90
|
+
);
|
|
61
91
|
if (compressionConfig.enabled !== false) {
|
|
62
92
|
middlewares.push(createCompressionMiddleware(compressionConfig));
|
|
63
93
|
}
|
|
64
94
|
|
|
65
95
|
// ── Query Helper ─────────────────────────────────────────
|
|
66
|
-
const queryConfig = normalizeOption<QueryHelperOptions>(options.queryHelper, {
|
|
96
|
+
const queryConfig = normalizeOption<QueryHelperOptions>(options.queryHelper, {
|
|
97
|
+
enabled: false,
|
|
98
|
+
});
|
|
67
99
|
if (queryConfig.enabled !== false) {
|
|
68
100
|
middlewares.push(createQueryHelperMiddleware(queryConfig));
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
// ── Logger (slow request detection) ──────────────────────
|
|
72
|
-
const loggerConfig = normalizeOption<LoggerOptions>(options.logSlowRequests, {
|
|
104
|
+
const loggerConfig = normalizeOption<LoggerOptions>(options.logSlowRequests, {
|
|
105
|
+
enabled: true,
|
|
106
|
+
});
|
|
73
107
|
if (loggerConfig.enabled !== false) {
|
|
74
108
|
middlewares.push(createLoggerMiddleware(loggerConfig, store));
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
// ── Cache ────────────────────────────────────────────────
|
|
78
|
-
const cacheConfig = normalizeOption<CacheOptions>(options.cache, {
|
|
112
|
+
const cacheConfig = normalizeOption<CacheOptions>(options.cache, {
|
|
113
|
+
enabled: false,
|
|
114
|
+
});
|
|
79
115
|
if (cacheConfig.enabled !== false) {
|
|
80
116
|
cacheMiddlewareInstance = createCacheMiddleware(cacheConfig, store);
|
|
81
117
|
middlewares.push(cacheMiddlewareInstance);
|
|
82
118
|
}
|
|
83
119
|
|
|
84
120
|
// ── Dashboard Router ─────────────────────────────────────
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
121
|
+
const dashboardRouter = createDashboardRouter(store, {
|
|
122
|
+
...dashboardConfig,
|
|
123
|
+
path: "/", // Always serve at the root of the provided router
|
|
124
|
+
});
|
|
88
125
|
|
|
89
126
|
// ── Composed Middleware ──────────────────────────────────
|
|
90
|
-
function composedMiddleware(
|
|
127
|
+
function composedMiddleware(
|
|
128
|
+
req: Request,
|
|
129
|
+
res: Response,
|
|
130
|
+
next: NextFunction,
|
|
131
|
+
): void {
|
|
91
132
|
let index = 0;
|
|
92
133
|
|
|
93
134
|
function runNext(err?: unknown): void {
|
|
@@ -117,25 +158,24 @@ export function performanceToolkit(options: ToolkitOptions = {}): ToolkitInstanc
|
|
|
117
158
|
|
|
118
159
|
/**
|
|
119
160
|
* Normalize a boolean | object option into a config object.
|
|
120
|
-
* - `true` → defaults with enabled: true
|
|
121
|
-
* - `false` → defaults with enabled: false
|
|
122
|
-
* - object → merged with defaults
|
|
123
161
|
*/
|
|
124
162
|
function normalizeOption<T extends { enabled?: boolean }>(
|
|
125
163
|
value: boolean | T | undefined,
|
|
126
|
-
defaults: T
|
|
164
|
+
defaults: T,
|
|
127
165
|
): T {
|
|
128
166
|
if (value === true) return { ...defaults, enabled: true };
|
|
129
167
|
if (value === false) return { ...defaults, enabled: false };
|
|
130
|
-
if (typeof value ===
|
|
168
|
+
if (typeof value === "object")
|
|
169
|
+
return { ...defaults, ...value, enabled: true };
|
|
131
170
|
return defaults;
|
|
132
171
|
}
|
|
133
172
|
|
|
134
173
|
// ── Re-exports ─────────────────────────────────────────────
|
|
135
|
-
export { MetricsStore } from
|
|
136
|
-
export { LRUCache, createCacheMiddleware } from
|
|
137
|
-
export { createCompressionMiddleware } from
|
|
138
|
-
export { createLoggerMiddleware } from
|
|
139
|
-
export { createQueryHelperMiddleware } from
|
|
140
|
-
export {
|
|
141
|
-
export
|
|
174
|
+
export { MetricsStore } from "./store";
|
|
175
|
+
export { LRUCache, createCacheMiddleware } from "./cache";
|
|
176
|
+
export { createCompressionMiddleware } from "./compression";
|
|
177
|
+
export { createLoggerMiddleware } from "./logger";
|
|
178
|
+
export { createQueryHelperMiddleware } from "./queryHelper";
|
|
179
|
+
export { createRateLimiter } from "./rateLimit";
|
|
180
|
+
export { createDashboardRouter } from "./dashboard/dashboardRouter";
|
|
181
|
+
export * from "./types";
|
package/src/logger.ts
CHANGED
|
@@ -1,42 +1,195 @@
|
|
|
1
|
-
import { Request, Response, NextFunction } from
|
|
2
|
-
import onFinished from
|
|
3
|
-
import
|
|
4
|
-
import
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import onFinished from "on-finished";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { LoggerOptions, LogEntry } from "./types";
|
|
6
|
+
import { MetricsStore } from "./store";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Default log formatter for console output.
|
|
8
10
|
*/
|
|
9
11
|
function defaultFormatter(entry: LogEntry): string {
|
|
10
|
-
const slow = entry.slow ?
|
|
11
|
-
const cached = entry.cached ?
|
|
12
|
+
const slow = entry.slow ? " 🔥 SLOW" : "";
|
|
13
|
+
const cached = entry.cached ? " [CACHED]" : "";
|
|
12
14
|
const status = entry.statusCode;
|
|
13
15
|
const time = `${entry.responseTime}ms`;
|
|
14
16
|
|
|
15
17
|
return `[perf] ${entry.method} ${entry.path} → ${status} ${time}${cached}${slow}`;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Helper class for managing log file rotation and cleanup.
|
|
22
|
+
*/
|
|
23
|
+
class LogRotator {
|
|
24
|
+
private currentStream: fs.WriteStream | null = null;
|
|
25
|
+
private currentDateStr: string = "";
|
|
26
|
+
private basePath: string;
|
|
27
|
+
private logDir: string;
|
|
28
|
+
private rotation: boolean;
|
|
29
|
+
private maxDays: number;
|
|
30
|
+
|
|
31
|
+
constructor(filePath: string, rotation: boolean, maxDays: number) {
|
|
32
|
+
this.basePath = path.resolve(process.cwd(), filePath);
|
|
33
|
+
this.logDir = path.dirname(this.basePath);
|
|
34
|
+
this.rotation = rotation;
|
|
35
|
+
this.maxDays = maxDays;
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(this.logDir)) {
|
|
38
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.getStream(); // Initialize
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private getDateStr(date: Date = new Date()): string {
|
|
45
|
+
return date.toISOString().split("T")[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private getRotatedPath(dateStr: string): string {
|
|
49
|
+
if (!this.rotation) return this.basePath;
|
|
50
|
+
const ext = path.extname(this.basePath);
|
|
51
|
+
const base = path.basename(this.basePath, ext);
|
|
52
|
+
return path.join(this.logDir, `${base}-${dateStr}${ext}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public getStream(): fs.WriteStream {
|
|
56
|
+
if (!this.rotation) {
|
|
57
|
+
if (!this.currentStream) {
|
|
58
|
+
this.currentStream = fs.createWriteStream(this.basePath, {
|
|
59
|
+
flags: "a",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return this.currentStream;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const todayStr = this.getDateStr();
|
|
66
|
+
if (this.currentDateStr !== todayStr) {
|
|
67
|
+
if (this.currentStream) {
|
|
68
|
+
this.currentStream.end();
|
|
69
|
+
}
|
|
70
|
+
this.currentDateStr = todayStr;
|
|
71
|
+
const newPath = this.getRotatedPath(todayStr);
|
|
72
|
+
this.currentStream = fs.createWriteStream(newPath, { flags: "a" });
|
|
73
|
+
|
|
74
|
+
// Run cleanup asynchronously in the background
|
|
75
|
+
this.cleanupOldLogs();
|
|
76
|
+
}
|
|
77
|
+
return this.currentStream!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private cleanupOldLogs() {
|
|
81
|
+
if (!this.rotation || this.maxDays <= 0) return;
|
|
82
|
+
|
|
83
|
+
fs.readdir(this.logDir, (err, files) => {
|
|
84
|
+
if (err) return;
|
|
85
|
+
|
|
86
|
+
const ext = path.extname(this.basePath);
|
|
87
|
+
const base = path.basename(this.basePath, ext);
|
|
88
|
+
const prefix = `${base}-`;
|
|
89
|
+
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const maxAgeMs = this.maxDays * 24 * 60 * 60 * 1000;
|
|
92
|
+
|
|
93
|
+
files.forEach((file) => {
|
|
94
|
+
if (file.startsWith(prefix) && file.endsWith(ext)) {
|
|
95
|
+
const datePart = file.slice(prefix.length, -ext.length);
|
|
96
|
+
const fileDate = new Date(datePart).getTime();
|
|
97
|
+
|
|
98
|
+
if (!isNaN(fileDate) && now - fileDate > maxAgeMs) {
|
|
99
|
+
fs.unlink(path.join(this.logDir, file), (unlinkErr) => {
|
|
100
|
+
if (unlinkErr)
|
|
101
|
+
console.error(
|
|
102
|
+
`[perf-toolkit] Failed to delete old log: ${file}`,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
18
112
|
/**
|
|
19
113
|
* Create request logging & slow API detection middleware.
|
|
20
114
|
*/
|
|
21
115
|
export function createLoggerMiddleware(
|
|
22
116
|
options: LoggerOptions = {},
|
|
23
|
-
store: MetricsStore
|
|
117
|
+
store: MetricsStore,
|
|
24
118
|
): (req: Request, res: Response, next: NextFunction) => void {
|
|
25
119
|
const {
|
|
26
120
|
slowThreshold = 1000,
|
|
27
121
|
console: logToConsole = true,
|
|
122
|
+
file: logFilePath,
|
|
123
|
+
rotation = false,
|
|
124
|
+
maxDays = 7,
|
|
28
125
|
formatter = defaultFormatter,
|
|
29
126
|
} = options;
|
|
30
127
|
|
|
128
|
+
let rotator: LogRotator | null = null;
|
|
129
|
+
if (logFilePath) {
|
|
130
|
+
try {
|
|
131
|
+
rotator = new LogRotator(logFilePath, rotation, maxDays);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error(`[perf-toolkit] Failed to initialize log rotator: ${err}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
31
137
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
32
138
|
const startTime = Date.now();
|
|
139
|
+
const reqPath = req.originalUrl || req.url;
|
|
140
|
+
|
|
141
|
+
// Ignore dashboard API paths
|
|
142
|
+
if (
|
|
143
|
+
reqPath.includes("/api/metrics") ||
|
|
144
|
+
reqPath.includes("/api/reset") ||
|
|
145
|
+
reqPath.includes("/api/__perf")
|
|
146
|
+
) {
|
|
147
|
+
return next();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Byte counting logic
|
|
151
|
+
let bytesSent = 0;
|
|
152
|
+
const originalWrite = res.write;
|
|
153
|
+
const originalEnd = res.end;
|
|
154
|
+
|
|
155
|
+
res.write = function (
|
|
156
|
+
chunk: any,
|
|
157
|
+
encoding?: any,
|
|
158
|
+
_callback?: any,
|
|
159
|
+
): boolean {
|
|
160
|
+
if (chunk) {
|
|
161
|
+
bytesSent += Buffer.isBuffer(chunk)
|
|
162
|
+
? chunk.length
|
|
163
|
+
: Buffer.byteLength(
|
|
164
|
+
chunk,
|
|
165
|
+
(typeof encoding === "string"
|
|
166
|
+
? encoding
|
|
167
|
+
: "utf8") as BufferEncoding,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return originalWrite.apply(res, arguments as any);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
res.end = function (chunk: any, encoding?: any, _callback?: any): Response {
|
|
174
|
+
if (chunk && typeof chunk !== "function") {
|
|
175
|
+
bytesSent += Buffer.isBuffer(chunk)
|
|
176
|
+
? chunk.length
|
|
177
|
+
: Buffer.byteLength(
|
|
178
|
+
chunk,
|
|
179
|
+
(typeof encoding === "string"
|
|
180
|
+
? encoding
|
|
181
|
+
: "utf8") as BufferEncoding,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return originalEnd.apply(res, arguments as any);
|
|
185
|
+
};
|
|
33
186
|
|
|
34
187
|
// Attach perf data to request
|
|
35
188
|
if (!req.perfToolkit) {
|
|
36
189
|
req.perfToolkit = {
|
|
37
190
|
startTime,
|
|
38
191
|
queryCount: 0,
|
|
39
|
-
trackQuery: (
|
|
192
|
+
trackQuery: () => {
|
|
40
193
|
req.perfToolkit!.queryCount++;
|
|
41
194
|
},
|
|
42
195
|
};
|
|
@@ -46,22 +199,27 @@ export function createLoggerMiddleware(
|
|
|
46
199
|
const responseTime = Date.now() - startTime;
|
|
47
200
|
const isSlow = responseTime >= slowThreshold;
|
|
48
201
|
|
|
202
|
+
// Extract route pattern if available (e.g. /users/:id)
|
|
203
|
+
const routePattern = (req as any).route?.path;
|
|
204
|
+
|
|
49
205
|
const entry: LogEntry = {
|
|
50
206
|
method: req.method,
|
|
51
|
-
path:
|
|
207
|
+
path: reqPath,
|
|
208
|
+
routePattern,
|
|
52
209
|
statusCode: finishedRes.statusCode,
|
|
53
210
|
responseTime,
|
|
54
211
|
timestamp: Date.now(),
|
|
55
212
|
slow: isSlow,
|
|
56
|
-
cached: res.getHeader(
|
|
213
|
+
cached: res.getHeader("X-Cache") === "HIT",
|
|
214
|
+
highQueries: req.perfToolkit?.highQueries || false,
|
|
57
215
|
queryCount: req.perfToolkit?.queryCount,
|
|
58
|
-
|
|
59
|
-
userAgent: req.get(
|
|
216
|
+
bytesSent,
|
|
217
|
+
userAgent: req.get("user-agent"),
|
|
60
218
|
ip: req.ip,
|
|
61
219
|
};
|
|
62
220
|
|
|
63
221
|
// Record in store
|
|
64
|
-
store.
|
|
222
|
+
store.recordLog(entry);
|
|
65
223
|
|
|
66
224
|
if (isSlow) {
|
|
67
225
|
store.recordSlowRequest();
|
|
@@ -76,6 +234,12 @@ export function createLoggerMiddleware(
|
|
|
76
234
|
console.log(message);
|
|
77
235
|
}
|
|
78
236
|
}
|
|
237
|
+
|
|
238
|
+
// File output
|
|
239
|
+
if (rotator) {
|
|
240
|
+
const logLine = JSON.stringify(entry) + "\n";
|
|
241
|
+
rotator.getStream().write(logLine);
|
|
242
|
+
}
|
|
79
243
|
});
|
|
80
244
|
|
|
81
245
|
next();
|
package/src/queryHelper.ts
CHANGED
|
@@ -31,6 +31,8 @@ export function createQueryHelperMiddleware(
|
|
|
31
31
|
|
|
32
32
|
// Warn if threshold exceeded
|
|
33
33
|
if (req.perfToolkit!.queryCount === threshold) {
|
|
34
|
+
req.perfToolkit!.highQueries = true;
|
|
35
|
+
|
|
34
36
|
console.warn(
|
|
35
37
|
`[perf] ⚠️ N+1 Alert: ${req.method} ${req.originalUrl || req.url} ` +
|
|
36
38
|
`has made ${threshold}+ queries. Consider optimizing with batch/join queries.`
|