adaptive-gateway 1.0.5
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 +7 -0
- package/README.md +58 -0
- package/package.json +39 -0
- package/src/analytics/cachePolicy.js +27 -0
- package/src/analytics/metricAnalyzer.js +18 -0
- package/src/cache/redisClient.js +11 -0
- package/src/database/db.js +9 -0
- package/src/index.js +21 -0
- package/src/jobs/cachePolicy.js +24 -0
- package/src/metrics/recordMetrics.js +24 -0
- package/src/proxy/forwarder.js +40 -0
- package/src/rateLimit/ratelimit.js +32 -0
- package/src/services/gateKeep.js +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adejare Omomofe
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Adaptive Gateway
|
|
2
|
+
|
|
3
|
+
`adaptive-gateway` is a lightweight Express-compatible gateway middleware with caching, circuit-breaker fallback, and rate limiting.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install adaptive-gateway
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import express from 'express';
|
|
15
|
+
import gateKeep from 'adaptive-gateway';
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
|
|
19
|
+
app.use(express.json());
|
|
20
|
+
|
|
21
|
+
app.use('/proxy', gateKeep({
|
|
22
|
+
target: 'http://localhost:3000',
|
|
23
|
+
ttl: 300,
|
|
24
|
+
latencyThreshold: 500,
|
|
25
|
+
pathRewrite: path => path.replace(/^\/proxy/, ''),
|
|
26
|
+
breakerOptions: {
|
|
27
|
+
timeout: 8000,
|
|
28
|
+
errorThresholdPercentage: 50,
|
|
29
|
+
resetTimeout: 10000
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
app.listen(4000, () => {
|
|
34
|
+
console.log('Gateway running on http://localhost:4000');
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Named exports
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { adaptiveGateway, createGateKeepMiddleware, gateKeep } from 'adaptive-gateway';
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- `adaptiveGateway(options)` — gateway wrapper with built-in rate limiting
|
|
45
|
+
- `createGateKeepMiddleware(options)` — reusable middleware factory
|
|
46
|
+
- `gateKeep` — alias for `createGateKeepMiddleware`
|
|
47
|
+
|
|
48
|
+
## Options
|
|
49
|
+
|
|
50
|
+
- `target` — backend URL to proxy to
|
|
51
|
+
- `ttl` — cache TTL in seconds
|
|
52
|
+
- `latencyThreshold` — threshold for request caching decisions
|
|
53
|
+
- `pathRewrite` — function to rewrite the incoming request path before proxying
|
|
54
|
+
- `breakerOptions` — opossum circuit breaker configuration
|
|
55
|
+
|
|
56
|
+
## Notes
|
|
57
|
+
|
|
58
|
+
This package is designed to be used in an Express app and assumes a Redis client and metrics pipeline are configured in the local project.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "adaptive-gateway",
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "Adaptive Express gateway middleware with caching, circuit breaker fallback, and rate limiting.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "nodemon examples/server.js",
|
|
15
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"express",
|
|
19
|
+
"gateway",
|
|
20
|
+
"middleware",
|
|
21
|
+
"proxy",
|
|
22
|
+
"cache",
|
|
23
|
+
"rate-limit",
|
|
24
|
+
"circuit-breaker"
|
|
25
|
+
],
|
|
26
|
+
"author": "Adejare Omomofe",
|
|
27
|
+
"license": "ISC",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"axios": "^1.16.0",
|
|
30
|
+
"express": "^5.2.1",
|
|
31
|
+
"node-cron": "^4.2.1",
|
|
32
|
+
"opossum": "^9.0.0",
|
|
33
|
+
"pg": "^8.20.0",
|
|
34
|
+
"redis": "^5.12.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"nodemon": "^3.1.14"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getEndpointStats } from './metricAnalyzer.js';
|
|
2
|
+
import redisClient from '../cache/redisClient.js';
|
|
3
|
+
|
|
4
|
+
export async function getCacheableEndpoints(latencyThreshold = 100, defaultTtl = 60) {
|
|
5
|
+
const stats = await getEndpointStats();
|
|
6
|
+
|
|
7
|
+
for (const stat of stats) {
|
|
8
|
+
const highLatency = stat.avgLatency > latencyThreshold;
|
|
9
|
+
const lowErrorRate = parseFloat(stat.errorRate) < 3;
|
|
10
|
+
const highTraffic = stat.requestCount > 100;
|
|
11
|
+
|
|
12
|
+
if (highLatency && lowErrorRate && highTraffic) {
|
|
13
|
+
let ttl = defaultTtl;
|
|
14
|
+
if (stat.avgLatency > latencyThreshold * 2) ttl = defaultTtl * 2;
|
|
15
|
+
if (stat.avgLatency > latencyThreshold * 3) ttl = defaultTtl * 5;
|
|
16
|
+
redisClient.set(`cacheable:${stat.endpoint}`, 'true', { EX: ttl });
|
|
17
|
+
} else {
|
|
18
|
+
redisClient.del(`cacheable:${stat.endpoint}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(`Endpoint: ${stat.endpoint}, Avg Latency: ${stat.avgLatency}ms, Error Rate: ${stat.errorRate}%`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return stats
|
|
25
|
+
.filter(stat => stat.avgLatency > latencyThreshold && parseFloat(stat.errorRate) < 3)
|
|
26
|
+
.map(stat => stat.endpoint);
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { pool } from "../database/db.js";
|
|
2
|
+
export async function getEndpointStats() {
|
|
3
|
+
const result = await pool.query(`
|
|
4
|
+
SELECT endpoint,
|
|
5
|
+
COUNT(*) AS total_requests,
|
|
6
|
+
AVG(response_time_ms) AS avg_latency,
|
|
7
|
+
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count
|
|
8
|
+
FROM metrics
|
|
9
|
+
GROUP BY endpoint
|
|
10
|
+
ORDER BY total_requests DESC
|
|
11
|
+
`);
|
|
12
|
+
return result.rows.map(row => ({
|
|
13
|
+
endpoint: row.endpoint,
|
|
14
|
+
totalRequests: parseInt(row.total_requests, 10),
|
|
15
|
+
avgLatency: parseFloat(row.avg_latency),
|
|
16
|
+
errorRate: `${((Number(row.error_count) / parseInt(row.total_requests, 10)) * 100).toFixed(2)}%`
|
|
17
|
+
}));
|
|
18
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
import { rateLimit } from './rateLimit/ratelimit.js';
|
|
3
|
+
import { createGateKeepMiddleware } from './services/gateKeep.js';
|
|
4
|
+
|
|
5
|
+
export const adaptiveGateway = (options) => {
|
|
6
|
+
const proxyMiddleware = createGateKeepMiddleware({
|
|
7
|
+
target: options.proxy.target,
|
|
8
|
+
ttl: options.cache.ttl,
|
|
9
|
+
latencyThreshold: options.cache.latencyThreshold,
|
|
10
|
+
pathRewrite: options.proxy.pathRewrite
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return async function (req, res, next) {
|
|
14
|
+
rateLimit(req, res, options.rateLimit.max, options.rateLimit.windowMs, async () => {
|
|
15
|
+
await proxyMiddleware(req, res, next);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export { createGateKeepMiddleware };
|
|
21
|
+
export default adaptiveGateway;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getCacheableEndpoints } from '../analytics/cachePolicy.js';
|
|
2
|
+
const CACHE_POLICY_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
export async function recalculateCachePolicy() {
|
|
5
|
+
console.log('Recalculating cache policy based on analytics data...');
|
|
6
|
+
// TODO: implement cache policy recalculation logic here using analytics data
|
|
7
|
+
await getCacheableEndpoints();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function startCachePolicyRecalculationJob() {
|
|
11
|
+
console.log('Starting cache policy recalculation job every 15 minutes.');
|
|
12
|
+
// Run immediately on startup, then every 15 minutes.
|
|
13
|
+
recalculateCachePolicy().catch(error => {
|
|
14
|
+
console.error('Failed to run initial cache policy recalculation:', error);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
setInterval(async () => {
|
|
18
|
+
try {
|
|
19
|
+
await recalculateCachePolicy();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error('Error during cache policy recalculation:', error);
|
|
22
|
+
}
|
|
23
|
+
}, CACHE_POLICY_REFRESH_INTERVAL_MS);
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { pool } from "../database/db.js";
|
|
2
|
+
|
|
3
|
+
export async function recordMetrics({ path, method, latency, status, error = false }) {
|
|
4
|
+
console.log({
|
|
5
|
+
path: path,
|
|
6
|
+
method: method,
|
|
7
|
+
latency: `${latency}ms`,
|
|
8
|
+
status: status,
|
|
9
|
+
error: error,
|
|
10
|
+
timestamp: new Date().toISOString()
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// try {
|
|
14
|
+
// await pool.query(
|
|
15
|
+
// `INSERT INTO metrics (endpoint, method, status_code, response_time_ms, is_error)
|
|
16
|
+
// VALUES ($1, $2, $3, $4, $5)`,
|
|
17
|
+
// [path, method, status, latency, error]
|
|
18
|
+
// );
|
|
19
|
+
// } catch (err) {
|
|
20
|
+
// console.error("Failed to insert metrics:", err.message);
|
|
21
|
+
// }
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { recordMetrics } from "../metrics/recordMetrics.js";
|
|
3
|
+
import redisClient from "../cache/redisClient.js";
|
|
4
|
+
import { getCacheableEndpoints } from "../analytics/cachePolicy.js";
|
|
5
|
+
|
|
6
|
+
const TARGET_BACKEND = 'http://localhost:3000';
|
|
7
|
+
|
|
8
|
+
export async function gateKeep(req, res, path, target = TARGET_BACKEND, ttl, latencyThreshold) {
|
|
9
|
+
const start = Date.now();
|
|
10
|
+
try {
|
|
11
|
+
if (req.method == 'GET' && (await getCacheableEndpoints()).includes(path)) {
|
|
12
|
+
const cacheKey = `${path}:${JSON.stringify(req.query)}`;
|
|
13
|
+
const cachedResponse = await redisClient.get(cacheKey);
|
|
14
|
+
if (cachedResponse) {
|
|
15
|
+
console.log(`Cache hit for ${cacheKey}`);
|
|
16
|
+
return res.json(JSON.parse(cachedResponse));
|
|
17
|
+
}
|
|
18
|
+
console.log(`Cache miss for ${cacheKey}`);
|
|
19
|
+
}
|
|
20
|
+
const response = await axios({
|
|
21
|
+
method: req.method,
|
|
22
|
+
url: `${target}${path}`,
|
|
23
|
+
data: req.body,
|
|
24
|
+
params: req.query,
|
|
25
|
+
})
|
|
26
|
+
const duration = Date.now() - start;
|
|
27
|
+
await recordMetrics({ path, method: req.method, latency: duration, status: response.status });
|
|
28
|
+
if (req.method == 'GET' && (await getCacheableEndpoints()).includes(path)) {
|
|
29
|
+
const cacheKey = `${path}:${JSON.stringify(req.query)}`;
|
|
30
|
+
await redisClient.set(cacheKey, JSON.stringify(response.data), { EX: ttl });
|
|
31
|
+
}
|
|
32
|
+
res.status(response.status).json(response.data);
|
|
33
|
+
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const duration = Date.now() - start;
|
|
36
|
+
console.error(`Error proxying request to ${path}:`, error.message);
|
|
37
|
+
await recordMetrics({ path, method: req.method, latency: duration, status: error.response?.status || 'Error', error: true });
|
|
38
|
+
res.status(error.response?.status || 500).json({ error: 'Internal Server Error' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const bucket = new Map();
|
|
2
|
+
|
|
3
|
+
export const rateLimit = (req, res, max, windowMs, next) => {
|
|
4
|
+
const key = req.ip;
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
|
|
7
|
+
if (!bucket.has(key)) {
|
|
8
|
+
bucket.set(key, {
|
|
9
|
+
tokens: max,
|
|
10
|
+
lastRefill: now
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const userBucket = bucket.get(key);
|
|
15
|
+
const elapsed = now - userBucket.lastRefill;
|
|
16
|
+
const refillAmount = (elapsed * max) / windowMs;
|
|
17
|
+
|
|
18
|
+
if (refillAmount > 0) {
|
|
19
|
+
userBucket.tokens = Math.min(max, userBucket.tokens + refillAmount);
|
|
20
|
+
userBucket.lastRefill = now;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (userBucket.tokens >= 1) {
|
|
24
|
+
userBucket.tokens -= 1;
|
|
25
|
+
bucket.set(key, userBucket);
|
|
26
|
+
next();
|
|
27
|
+
} else {
|
|
28
|
+
res.status(429).json({
|
|
29
|
+
error: 'Too Many Requests'
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import CircuitBreaker from 'opossum';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import redisClient from '../cache/redisClient.js';
|
|
4
|
+
import { recordMetrics } from '../metrics/recordMetrics.js';
|
|
5
|
+
import { getCacheableEndpoints } from '../analytics/cachePolicy.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TARGET = 'http://localhost:3000';
|
|
8
|
+
const DEFAULT_TTL = 300;
|
|
9
|
+
const DEFAULT_LATENCY_THRESHOLD = 500;
|
|
10
|
+
const DEFAULT_BREAKER_OPTIONS = {
|
|
11
|
+
timeout: 8000,
|
|
12
|
+
errorThresholdPercentage: 50,
|
|
13
|
+
resetTimeout: 10000
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function buildCacheKey(target, path, query) {
|
|
17
|
+
return `${target}:${path}:${JSON.stringify(query ?? {})}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createBreaker(options) {
|
|
21
|
+
const breaker = new CircuitBreaker(async (target, path, req) => {
|
|
22
|
+
const response = await axios({
|
|
23
|
+
method: req.method,
|
|
24
|
+
url: `${target}${path}`,
|
|
25
|
+
data: req.body,
|
|
26
|
+
params: req.query,
|
|
27
|
+
validateStatus: status => status < 500
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (response.status >= 500) {
|
|
31
|
+
const error = new Error(`Upstream server error: ${response.status}`);
|
|
32
|
+
error.response = response;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return response;
|
|
37
|
+
}, options);
|
|
38
|
+
|
|
39
|
+
breaker.fallback(async (target, path, req) => {
|
|
40
|
+
const cacheKey = buildCacheKey(target, path, req.query);
|
|
41
|
+
const stale = await redisClient.get(cacheKey);
|
|
42
|
+
if (stale) {
|
|
43
|
+
return {
|
|
44
|
+
status: 200,
|
|
45
|
+
data: JSON.parse(stale),
|
|
46
|
+
stale: true
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error('Service unavailable');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return breaker;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createGateKeepMiddleware({
|
|
57
|
+
target = DEFAULT_TARGET,
|
|
58
|
+
ttl = DEFAULT_TTL,
|
|
59
|
+
latencyThreshold = DEFAULT_LATENCY_THRESHOLD,
|
|
60
|
+
pathRewrite = path => path,
|
|
61
|
+
breakerOptions = {}
|
|
62
|
+
} = {}) {
|
|
63
|
+
const breaker = createBreaker({ ...DEFAULT_BREAKER_OPTIONS, ...breakerOptions });
|
|
64
|
+
|
|
65
|
+
return async function gateKeepMiddleware(req, res, next) {
|
|
66
|
+
const path = pathRewrite(req.path);
|
|
67
|
+
const cacheKey = buildCacheKey(target, path, req.query);
|
|
68
|
+
const shouldCache = req.method === 'GET' && (await getCacheableEndpoints(latencyThreshold)).includes(path);
|
|
69
|
+
|
|
70
|
+
if (shouldCache) {
|
|
71
|
+
const cached = await redisClient.get(cacheKey);
|
|
72
|
+
if (cached) {
|
|
73
|
+
return res.json(JSON.parse(cached));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const start = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
const response = await breaker.fire(target, path, req);
|
|
80
|
+
const duration = Date.now() - start;
|
|
81
|
+
await recordMetrics({ path, method: req.method, latency: duration, status: response.status });
|
|
82
|
+
|
|
83
|
+
if (shouldCache) {
|
|
84
|
+
await redisClient.set(cacheKey, JSON.stringify(response.data), { EX: ttl });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return res.status(response.status).json(response.data);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const duration = Date.now() - start;
|
|
90
|
+
await recordMetrics({ path, method: req.method, latency: duration, status: error.response?.status || 503, error: true });
|
|
91
|
+
return res.status(error.response?.status || 503).json({
|
|
92
|
+
error: 'Service unavailable',
|
|
93
|
+
details: error.message
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const gateKeep = createGateKeepMiddleware;
|