express-performance-toolkit 1.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 +217 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +182 -0
- package/dist/cache.js.map +1 -0
- package/dist/compression.d.ts +7 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +26 -0
- package/dist/compression.js.map +1 -0
- package/dist/dashboard/dashboard.html +756 -0
- package/dist/dashboard/dashboardRouter.d.ts +9 -0
- package/dist/dashboard/dashboardRouter.d.ts.map +1 -0
- package/dist/dashboard/dashboardRouter.js +71 -0
- package/dist/dashboard/dashboardRouter.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +130 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +70 -0
- package/dist/logger.js.map +1 -0
- package/dist/queryHelper.d.ts +8 -0
- package/dist/queryHelper.d.ts.map +1 -0
- package/dist/queryHelper.js +39 -0
- package/dist/queryHelper.js.map +1 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +108 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/example/server.ts +126 -0
- package/example/tsconfig.json +17 -0
- package/jest.config.js +10 -0
- package/package.json +57 -0
- package/src/cache.ts +228 -0
- package/src/compression.ts +25 -0
- package/src/dashboard/dashboard.html +756 -0
- package/src/dashboard/dashboardRouter.ts +45 -0
- package/src/index.ts +141 -0
- package/src/logger.ts +83 -0
- package/src/queryHelper.ts +49 -0
- package/src/store.ts +134 -0
- package/src/types.ts +155 -0
- package/tests/cache.test.ts +76 -0
- package/tests/integration.test.ts +124 -0
- package/tests/store.test.ts +103 -0
- package/tsconfig.json +21 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
export interface CacheOptions {
|
|
3
|
+
/** Enable caching (default: true) */
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
/** Cache TTL in milliseconds (default: 60000) */
|
|
6
|
+
ttl?: number;
|
|
7
|
+
/** Max entries in LRU cache (default: 100) */
|
|
8
|
+
maxSize?: number;
|
|
9
|
+
/** URL patterns to exclude from caching */
|
|
10
|
+
exclude?: (string | RegExp)[];
|
|
11
|
+
/** HTTP methods to cache (default: ['GET']) */
|
|
12
|
+
methods?: string[];
|
|
13
|
+
/** Redis config — requires ioredis as peer dep */
|
|
14
|
+
redis?: RedisConfig | null;
|
|
15
|
+
}
|
|
16
|
+
export interface RedisConfig {
|
|
17
|
+
host?: string;
|
|
18
|
+
port?: number;
|
|
19
|
+
password?: string;
|
|
20
|
+
db?: number;
|
|
21
|
+
prefix?: string;
|
|
22
|
+
ttl?: number;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface CompressionOptions {
|
|
26
|
+
/** Enable compression (default: true) */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** Minimum response size to compress in bytes (default: 1024) */
|
|
29
|
+
threshold?: number;
|
|
30
|
+
/** Compression level 1-9 (default: 6) */
|
|
31
|
+
level?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface LoggerOptions {
|
|
34
|
+
/** Enable request logging (default: true) */
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
/** Slow request threshold in ms (default: 1000) */
|
|
37
|
+
slowThreshold?: number;
|
|
38
|
+
/** Log to console (default: true) */
|
|
39
|
+
console?: boolean;
|
|
40
|
+
/** Custom log formatter */
|
|
41
|
+
formatter?: (entry: LogEntry) => string;
|
|
42
|
+
}
|
|
43
|
+
export interface QueryHelperOptions {
|
|
44
|
+
/** Enable query tracking (default: false) */
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
/** Warn if more than this many queries per request (default: 10) */
|
|
47
|
+
threshold?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface DashboardOptions {
|
|
50
|
+
/** Enable dashboard (default: true) */
|
|
51
|
+
enabled?: boolean;
|
|
52
|
+
/** Dashboard mount path (default: '/__perf') */
|
|
53
|
+
path?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface ToolkitOptions {
|
|
56
|
+
/** Cache configuration — pass true for defaults or an object to customize */
|
|
57
|
+
cache?: boolean | CacheOptions;
|
|
58
|
+
/** Compression configuration — pass true for defaults or an object */
|
|
59
|
+
compression?: boolean | CompressionOptions;
|
|
60
|
+
/** Slow request detection — pass true for defaults or an object */
|
|
61
|
+
logSlowRequests?: boolean | LoggerOptions;
|
|
62
|
+
/** Query optimization helper */
|
|
63
|
+
queryHelper?: boolean | QueryHelperOptions;
|
|
64
|
+
/** Performance dashboard */
|
|
65
|
+
dashboard?: boolean | DashboardOptions;
|
|
66
|
+
/** Max log entries to keep in memory (default: 1000) */
|
|
67
|
+
maxLogs?: number;
|
|
68
|
+
}
|
|
69
|
+
export interface LogEntry {
|
|
70
|
+
method: string;
|
|
71
|
+
path: string;
|
|
72
|
+
statusCode: number;
|
|
73
|
+
responseTime: number;
|
|
74
|
+
timestamp: number;
|
|
75
|
+
slow: boolean;
|
|
76
|
+
cached: boolean;
|
|
77
|
+
queryCount?: number;
|
|
78
|
+
contentLength?: number;
|
|
79
|
+
userAgent?: string;
|
|
80
|
+
ip?: string;
|
|
81
|
+
}
|
|
82
|
+
export interface RouteStats {
|
|
83
|
+
count: number;
|
|
84
|
+
totalTime: number;
|
|
85
|
+
slowCount: number;
|
|
86
|
+
avgTime: number;
|
|
87
|
+
}
|
|
88
|
+
export interface Metrics {
|
|
89
|
+
uptime: number;
|
|
90
|
+
totalRequests: number;
|
|
91
|
+
avgResponseTime: number;
|
|
92
|
+
slowRequests: number;
|
|
93
|
+
cacheHits: number;
|
|
94
|
+
cacheMisses: number;
|
|
95
|
+
cacheHitRate: number;
|
|
96
|
+
cacheSize: number;
|
|
97
|
+
statusCodes: Record<number, number>;
|
|
98
|
+
routes: Record<string, RouteStats>;
|
|
99
|
+
recentLogs: LogEntry[];
|
|
100
|
+
}
|
|
101
|
+
export interface CacheEntry {
|
|
102
|
+
body: string | Buffer;
|
|
103
|
+
statusCode: number;
|
|
104
|
+
contentType: string | undefined;
|
|
105
|
+
}
|
|
106
|
+
export interface LRUCacheEntry<T> {
|
|
107
|
+
value: T;
|
|
108
|
+
createdAt: number;
|
|
109
|
+
}
|
|
110
|
+
export interface CacheAdapter<T = CacheEntry> {
|
|
111
|
+
get(key: string): T | null | Promise<T | null>;
|
|
112
|
+
set(key: string, value: T): void | Promise<void>;
|
|
113
|
+
has(key: string): boolean | Promise<boolean>;
|
|
114
|
+
delete(key: string): boolean | void | Promise<boolean | void>;
|
|
115
|
+
clear(): void | Promise<void>;
|
|
116
|
+
readonly size: number;
|
|
117
|
+
}
|
|
118
|
+
export interface CacheMiddleware {
|
|
119
|
+
(req: Request, res: Response, next: NextFunction): void;
|
|
120
|
+
clear: () => void | Promise<void>;
|
|
121
|
+
delete: (key: string) => boolean | void | Promise<boolean | void>;
|
|
122
|
+
adapter: CacheAdapter;
|
|
123
|
+
}
|
|
124
|
+
declare global {
|
|
125
|
+
namespace Express {
|
|
126
|
+
interface Request {
|
|
127
|
+
perfToolkit?: {
|
|
128
|
+
startTime: number;
|
|
129
|
+
queryCount: number;
|
|
130
|
+
trackQuery: (label?: string) => void;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import express, { Request, Response } from 'express';
|
|
2
|
+
import { performanceToolkit } from '../src/index';
|
|
3
|
+
|
|
4
|
+
const app = express();
|
|
5
|
+
const PORT = 3000;
|
|
6
|
+
|
|
7
|
+
// ── Initialize Performance Toolkit ──────────────────────────
|
|
8
|
+
const toolkit = performanceToolkit({
|
|
9
|
+
cache: {
|
|
10
|
+
ttl: 30000, // 30s cache TTL
|
|
11
|
+
maxSize: 50,
|
|
12
|
+
exclude: ['/api/random', '/__perf'],
|
|
13
|
+
},
|
|
14
|
+
compression: true,
|
|
15
|
+
logSlowRequests: {
|
|
16
|
+
slowThreshold: 500, // Flag requests > 500ms as slow
|
|
17
|
+
console: true,
|
|
18
|
+
},
|
|
19
|
+
queryHelper: {
|
|
20
|
+
threshold: 5,
|
|
21
|
+
},
|
|
22
|
+
dashboard: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Apply the middleware
|
|
26
|
+
app.use(toolkit.middleware);
|
|
27
|
+
|
|
28
|
+
// Mount the dashboard
|
|
29
|
+
app.use('/__perf', toolkit.dashboardRouter);
|
|
30
|
+
|
|
31
|
+
// ── Sample API Routes ───────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
// Fast route — should be cached after first hit
|
|
34
|
+
app.get('/api/users', (_req: Request, res: Response) => {
|
|
35
|
+
const users = [
|
|
36
|
+
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' },
|
|
37
|
+
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'user' },
|
|
38
|
+
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'user' },
|
|
39
|
+
{ id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'moderator' },
|
|
40
|
+
{ id: 5, name: 'Eve Wilson', email: 'eve@example.com', role: 'user' },
|
|
41
|
+
];
|
|
42
|
+
res.json({ users, count: users.length });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Slow route — simulates a 1.5s delay (triggers slow detection)
|
|
46
|
+
app.get('/api/slow', (_req: Request, res: Response) => {
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
res.json({
|
|
49
|
+
message: 'This response was intentionally delayed',
|
|
50
|
+
processingTime: '1500ms',
|
|
51
|
+
});
|
|
52
|
+
}, 1500);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Medium-speed route
|
|
56
|
+
app.get('/api/products', (_req: Request, res: Response) => {
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
res.json({
|
|
59
|
+
products: [
|
|
60
|
+
{ id: 1, name: 'Laptop Pro', price: 1299.99, stock: 25 },
|
|
61
|
+
{ id: 2, name: 'Wireless Mouse', price: 29.99, stock: 150 },
|
|
62
|
+
{ id: 3, name: 'USB-C Hub', price: 49.99, stock: 75 },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
}, 300);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Random data — excluded from cache
|
|
69
|
+
app.get('/api/random', (_req: Request, res: Response) => {
|
|
70
|
+
res.json({
|
|
71
|
+
value: Math.random(),
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Route demonstrating query tracking
|
|
77
|
+
app.get('/api/posts', (req: Request, res: Response) => {
|
|
78
|
+
// Simulate multiple DB queries (potential N+1)
|
|
79
|
+
const posts = [];
|
|
80
|
+
for (let i = 0; i < 12; i++) {
|
|
81
|
+
req.perfToolkit?.trackQuery(`SELECT * FROM comments WHERE post_id=${i}`);
|
|
82
|
+
posts.push({
|
|
83
|
+
id: i,
|
|
84
|
+
title: `Post ${i}`,
|
|
85
|
+
commentCount: Math.floor(Math.random() * 20),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
res.json({ posts });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// POST route — not cached
|
|
92
|
+
app.post('/api/users', express.json(), (req: Request, res: Response) => {
|
|
93
|
+
res.status(201).json({
|
|
94
|
+
message: 'User created',
|
|
95
|
+
user: req.body,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Error route
|
|
100
|
+
app.get('/api/error', (_req: Request, _res: Response) => {
|
|
101
|
+
throw new Error('Something went wrong!');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Error handler
|
|
105
|
+
app.use((err: Error, _req: Request, res: Response, _next: express.NextFunction) => {
|
|
106
|
+
console.error(err.stack);
|
|
107
|
+
res.status(500).json({ error: err.message });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Start Server ────────────────────────────────────────────
|
|
111
|
+
app.listen(PORT, () => {
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(' ⚡ Express Performance Toolkit — Example Server');
|
|
114
|
+
console.log(' ────────────────────────────────────────────────');
|
|
115
|
+
console.log(` 🚀 Server: http://localhost:${PORT}`);
|
|
116
|
+
console.log(` 📊 Dashboard: http://localhost:${PORT}/__perf`);
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(' Try these endpoints:');
|
|
119
|
+
console.log(` GET http://localhost:${PORT}/api/users (fast, cached)`);
|
|
120
|
+
console.log(` GET http://localhost:${PORT}/api/slow (slow, triggers alert)`);
|
|
121
|
+
console.log(` GET http://localhost:${PORT}/api/products (medium speed)`);
|
|
122
|
+
console.log(` GET http://localhost:${PORT}/api/random (not cached)`);
|
|
123
|
+
console.log(` GET http://localhost:${PORT}/api/posts (N+1 query warning)`);
|
|
124
|
+
console.log(` POST http://localhost:${PORT}/api/users (not cached)`);
|
|
125
|
+
console.log('');
|
|
126
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"moduleResolution": "node",
|
|
12
|
+
"outDir": "./dist",
|
|
13
|
+
"rootDir": "."
|
|
14
|
+
},
|
|
15
|
+
"include": ["server.ts"],
|
|
16
|
+
"references": [{ "path": ".." }]
|
|
17
|
+
}
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: 'ts-jest',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
roots: ['<rootDir>/tests'],
|
|
6
|
+
testMatch: ['**/*.test.ts'],
|
|
7
|
+
moduleFileExtensions: ['ts', 'js', 'json'],
|
|
8
|
+
collectCoverageFrom: ['src/**/*.ts', '!src/dashboard/dashboard.html'],
|
|
9
|
+
coverageDirectory: 'coverage',
|
|
10
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "express-performance-toolkit",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"postbuild": "npm run copy-assets",
|
|
10
|
+
"copy-assets": "mkdir -p dist/dashboard && cp src/dashboard/dashboard.html dist/dashboard/",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"test": "jest --verbose",
|
|
13
|
+
"example": "ts-node example/server.ts",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"express",
|
|
18
|
+
"performance",
|
|
19
|
+
"middleware",
|
|
20
|
+
"caching",
|
|
21
|
+
"compression",
|
|
22
|
+
"monitoring",
|
|
23
|
+
"dashboard",
|
|
24
|
+
"slow-api",
|
|
25
|
+
"optimization",
|
|
26
|
+
"logging",
|
|
27
|
+
"typescript"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"compression": "^1.7.4",
|
|
33
|
+
"on-finished": "^2.4.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"express": ">=4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"ioredis": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/compression": "^1.7.5",
|
|
45
|
+
"@types/express": "^4.17.21",
|
|
46
|
+
"@types/jest": "^29.5.12",
|
|
47
|
+
"@types/node": "^20.11.0",
|
|
48
|
+
"@types/on-finished": "^2.3.4",
|
|
49
|
+
"@types/supertest": "^7.2.0",
|
|
50
|
+
"express": "^4.21.0",
|
|
51
|
+
"jest": "^29.7.0",
|
|
52
|
+
"supertest": "^6.3.3",
|
|
53
|
+
"ts-jest": "^29.1.2",
|
|
54
|
+
"ts-node": "^10.9.2",
|
|
55
|
+
"typescript": "^5.3.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import {
|
|
3
|
+
CacheOptions,
|
|
4
|
+
CacheEntry,
|
|
5
|
+
CacheAdapter,
|
|
6
|
+
LRUCacheEntry,
|
|
7
|
+
CacheMiddleware,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { MetricsStore } from "./store";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory LRU cache with TTL support.
|
|
13
|
+
*/
|
|
14
|
+
export class LRUCache<T = CacheEntry> implements CacheAdapter<T> {
|
|
15
|
+
private maxSize: number;
|
|
16
|
+
private ttl: number;
|
|
17
|
+
private cache: Map<string, LRUCacheEntry<T>>;
|
|
18
|
+
|
|
19
|
+
constructor(options: { maxSize?: number; ttl?: number } = {}) {
|
|
20
|
+
this.maxSize = options.maxSize || 100;
|
|
21
|
+
this.ttl = options.ttl || 60000;
|
|
22
|
+
this.cache = new Map();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(key: string): T | null {
|
|
26
|
+
const entry = this.cache.get(key);
|
|
27
|
+
if (!entry) return null;
|
|
28
|
+
|
|
29
|
+
// Check TTL
|
|
30
|
+
if (Date.now() - entry.createdAt > this.ttl) {
|
|
31
|
+
this.cache.delete(key);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Move to end (most recently used)
|
|
36
|
+
this.cache.delete(key);
|
|
37
|
+
this.cache.set(key, entry);
|
|
38
|
+
return entry.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
set(key: string, value: T): void {
|
|
42
|
+
// Remove oldest if at capacity
|
|
43
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
44
|
+
const firstKey = this.cache.keys().next().value;
|
|
45
|
+
if (firstKey !== undefined) {
|
|
46
|
+
this.cache.delete(firstKey);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.cache.set(key, {
|
|
51
|
+
value,
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
has(key: string): boolean {
|
|
57
|
+
return this.get(key) !== null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
delete(key: string): boolean {
|
|
61
|
+
return this.cache.delete(key);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear(): void {
|
|
65
|
+
this.cache.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get size(): number {
|
|
69
|
+
return this.cache.size;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a Redis cache adapter (requires ioredis as peer dependency).
|
|
75
|
+
*/
|
|
76
|
+
function createRedisAdapter(
|
|
77
|
+
redisConfig: Record<string, unknown>,
|
|
78
|
+
): CacheAdapter | null {
|
|
79
|
+
try {
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
81
|
+
const Redis = require("ioredis");
|
|
82
|
+
const client = new Redis(redisConfig);
|
|
83
|
+
const prefix = (redisConfig.prefix as string) || "ept:";
|
|
84
|
+
const ttl = (redisConfig.ttl as number) || 60;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
async get(key: string): Promise<CacheEntry | null> {
|
|
88
|
+
const data = await client.get(`${prefix}${key}`);
|
|
89
|
+
return data ? JSON.parse(data) : null;
|
|
90
|
+
},
|
|
91
|
+
async set(key: string, value: CacheEntry): Promise<void> {
|
|
92
|
+
await client.setex(`${prefix}${key}`, ttl, JSON.stringify(value));
|
|
93
|
+
},
|
|
94
|
+
async has(key: string): Promise<boolean> {
|
|
95
|
+
return (await client.exists(`${prefix}${key}`)) === 1;
|
|
96
|
+
},
|
|
97
|
+
async delete(key: string): Promise<void> {
|
|
98
|
+
await client.del(`${prefix}${key}`);
|
|
99
|
+
},
|
|
100
|
+
async clear(): Promise<void> {
|
|
101
|
+
const keys = await client.keys(`${prefix}*`);
|
|
102
|
+
if (keys.length > 0) await client.del(...keys);
|
|
103
|
+
},
|
|
104
|
+
get size(): number {
|
|
105
|
+
return -1; // Cannot easily get size from Redis
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
console.warn(
|
|
110
|
+
"[express-performance-toolkit] ioredis not installed. Falling back to in-memory cache.",
|
|
111
|
+
);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create cache middleware.
|
|
118
|
+
*/
|
|
119
|
+
export function createCacheMiddleware(
|
|
120
|
+
options: CacheOptions = {},
|
|
121
|
+
store?: MetricsStore,
|
|
122
|
+
): CacheMiddleware {
|
|
123
|
+
const {
|
|
124
|
+
ttl = 60000,
|
|
125
|
+
maxSize = 100,
|
|
126
|
+
exclude = [],
|
|
127
|
+
redis = null,
|
|
128
|
+
methods = ["GET"],
|
|
129
|
+
} = options;
|
|
130
|
+
|
|
131
|
+
let cacheAdapter: CacheAdapter;
|
|
132
|
+
|
|
133
|
+
if (redis) {
|
|
134
|
+
const redisAdapter = createRedisAdapter({
|
|
135
|
+
...redis,
|
|
136
|
+
ttl: Math.ceil(ttl / 1000),
|
|
137
|
+
});
|
|
138
|
+
cacheAdapter = redisAdapter || new LRUCache({ maxSize, ttl });
|
|
139
|
+
} else {
|
|
140
|
+
cacheAdapter = new LRUCache({ maxSize, ttl });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shouldExclude(url: string): boolean {
|
|
144
|
+
return exclude.some((pattern) => {
|
|
145
|
+
if (pattern instanceof RegExp) return pattern.test(url);
|
|
146
|
+
return url.includes(pattern);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getCacheKey(req: Request): string {
|
|
151
|
+
return `${req.method}:${req.originalUrl || req.url}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const handler = async (
|
|
155
|
+
req: Request,
|
|
156
|
+
res: Response,
|
|
157
|
+
next: NextFunction,
|
|
158
|
+
): Promise<void> => {
|
|
159
|
+
// Only cache specified methods
|
|
160
|
+
if (!methods.includes(req.method)) {
|
|
161
|
+
return next();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check exclusion patterns
|
|
165
|
+
const url = req.originalUrl || req.url;
|
|
166
|
+
if (shouldExclude(url)) {
|
|
167
|
+
return next();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const key = getCacheKey(req);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const cached = await cacheAdapter.get(key);
|
|
174
|
+
if (cached) {
|
|
175
|
+
if (store) store.recordCacheHit();
|
|
176
|
+
const entry = cached as CacheEntry;
|
|
177
|
+
|
|
178
|
+
res.set("X-Cache", "HIT");
|
|
179
|
+
res.set("Content-Type", entry.contentType || "application/json");
|
|
180
|
+
res.status(entry.statusCode || 200).send(entry.body);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// Cache read failed — continue to handler
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (store) store.recordCacheMiss();
|
|
188
|
+
res.set("X-Cache", "MISS");
|
|
189
|
+
|
|
190
|
+
// Intercept response to cache it
|
|
191
|
+
const originalSend = res.send.bind(res);
|
|
192
|
+
res.send = function (body: unknown): Response {
|
|
193
|
+
// Only cache successful responses
|
|
194
|
+
if (res.statusCode >= 200 && res.statusCode < 400) {
|
|
195
|
+
const entry: CacheEntry = {
|
|
196
|
+
body: body as string | Buffer,
|
|
197
|
+
statusCode: res.statusCode,
|
|
198
|
+
contentType: res.get("Content-Type"),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
cacheAdapter.set(key, entry);
|
|
203
|
+
if (
|
|
204
|
+
store &&
|
|
205
|
+
typeof cacheAdapter.size === "number" &&
|
|
206
|
+
cacheAdapter.size >= 0
|
|
207
|
+
) {
|
|
208
|
+
store.setCacheSize(cacheAdapter.size);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// Cache write failed — ignore
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return originalSend(body);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
next();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Build the CacheMiddleware with attached control methods
|
|
222
|
+
const middleware = handler as unknown as CacheMiddleware;
|
|
223
|
+
middleware.clear = () => cacheAdapter.clear();
|
|
224
|
+
middleware.delete = (key: string) => cacheAdapter.delete(key);
|
|
225
|
+
middleware.adapter = cacheAdapter;
|
|
226
|
+
|
|
227
|
+
return middleware;
|
|
228
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import compressionMiddleware from 'compression';
|
|
3
|
+
import { CompressionOptions } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create compression middleware with sensible defaults.
|
|
7
|
+
*/
|
|
8
|
+
export function createCompressionMiddleware(
|
|
9
|
+
options: CompressionOptions = {}
|
|
10
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
11
|
+
const { threshold = 1024, level = 6 } = options;
|
|
12
|
+
|
|
13
|
+
return compressionMiddleware({
|
|
14
|
+
threshold,
|
|
15
|
+
level,
|
|
16
|
+
filter: (req: Request, res: Response): boolean => {
|
|
17
|
+
// Don't compress if the client didn't request it
|
|
18
|
+
if (req.headers['x-no-compression']) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
// Use compression's default filter
|
|
22
|
+
return compressionMiddleware.filter(req, res);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|