@hazeljs/gateway 0.2.0-beta.41
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 +192 -0
- package/README.md +255 -0
- package/dist/__tests__/canary-engine.test.d.ts +2 -0
- package/dist/__tests__/canary-engine.test.d.ts.map +1 -0
- package/dist/__tests__/canary-engine.test.js +133 -0
- package/dist/__tests__/decorators.test.d.ts +2 -0
- package/dist/__tests__/decorators.test.d.ts.map +1 -0
- package/dist/__tests__/decorators.test.js +174 -0
- package/dist/__tests__/from-config.test.d.ts +2 -0
- package/dist/__tests__/from-config.test.d.ts.map +1 -0
- package/dist/__tests__/from-config.test.js +67 -0
- package/dist/__tests__/gateway-metrics.test.d.ts +2 -0
- package/dist/__tests__/gateway-metrics.test.d.ts.map +1 -0
- package/dist/__tests__/gateway-metrics.test.js +82 -0
- package/dist/__tests__/gateway-module.test.d.ts +2 -0
- package/dist/__tests__/gateway-module.test.d.ts.map +1 -0
- package/dist/__tests__/gateway-module.test.js +91 -0
- package/dist/__tests__/gateway.test.d.ts +2 -0
- package/dist/__tests__/gateway.test.d.ts.map +1 -0
- package/dist/__tests__/gateway.test.js +257 -0
- package/dist/__tests__/hazel-integration.test.d.ts +2 -0
- package/dist/__tests__/hazel-integration.test.d.ts.map +1 -0
- package/dist/__tests__/hazel-integration.test.js +92 -0
- package/dist/__tests__/route-matcher.test.d.ts +2 -0
- package/dist/__tests__/route-matcher.test.d.ts.map +1 -0
- package/dist/__tests__/route-matcher.test.js +67 -0
- package/dist/__tests__/service-proxy.test.d.ts +2 -0
- package/dist/__tests__/service-proxy.test.d.ts.map +1 -0
- package/dist/__tests__/service-proxy.test.js +110 -0
- package/dist/__tests__/traffic-mirror.test.d.ts +2 -0
- package/dist/__tests__/traffic-mirror.test.d.ts.map +1 -0
- package/dist/__tests__/traffic-mirror.test.js +70 -0
- package/dist/__tests__/version-router.test.d.ts +2 -0
- package/dist/__tests__/version-router.test.d.ts.map +1 -0
- package/dist/__tests__/version-router.test.js +136 -0
- package/dist/canary/canary-engine.d.ts +107 -0
- package/dist/canary/canary-engine.d.ts.map +1 -0
- package/dist/canary/canary-engine.js +334 -0
- package/dist/decorators/index.d.ts +74 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +170 -0
- package/dist/gateway.d.ts +67 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +310 -0
- package/dist/gateway.module.d.ts +67 -0
- package/dist/gateway.module.d.ts.map +1 -0
- package/dist/gateway.module.js +82 -0
- package/dist/hazel-integration.d.ts +24 -0
- package/dist/hazel-integration.d.ts.map +1 -0
- package/dist/hazel-integration.js +70 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/metrics/gateway-metrics.d.ts +64 -0
- package/dist/metrics/gateway-metrics.d.ts.map +1 -0
- package/dist/metrics/gateway-metrics.js +159 -0
- package/dist/middleware/traffic-mirror.d.ts +19 -0
- package/dist/middleware/traffic-mirror.d.ts.map +1 -0
- package/dist/middleware/traffic-mirror.js +60 -0
- package/dist/proxy/service-proxy.d.ts +68 -0
- package/dist/proxy/service-proxy.d.ts.map +1 -0
- package/dist/proxy/service-proxy.js +211 -0
- package/dist/routing/route-matcher.d.ts +31 -0
- package/dist/routing/route-matcher.d.ts.map +1 -0
- package/dist/routing/route-matcher.js +112 -0
- package/dist/routing/version-router.d.ts +36 -0
- package/dist/routing/version-router.d.ts.map +1 -0
- package/dist/routing/version-router.js +136 -0
- package/dist/types/index.d.ts +217 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +17 -0
- package/package.json +74 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Service Proxy
|
|
4
|
+
* Enhanced HTTP client that integrates discovery, resilience, and traffic policies.
|
|
5
|
+
* The core request engine of the gateway.
|
|
6
|
+
*/
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ServiceProxy = void 0;
|
|
12
|
+
const axios_1 = __importDefault(require("axios"));
|
|
13
|
+
const resilience_1 = require("@hazeljs/resilience");
|
|
14
|
+
class ServiceProxy {
|
|
15
|
+
constructor(discoveryClient, config) {
|
|
16
|
+
this.discoveryClient = discoveryClient;
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.axiosInstance = axios_1.default.create({
|
|
19
|
+
timeout: config.timeout ?? 10000,
|
|
20
|
+
});
|
|
21
|
+
this.metrics = new resilience_1.MetricsCollector(60000);
|
|
22
|
+
// Initialize resilience components
|
|
23
|
+
if (config.retry) {
|
|
24
|
+
this.retryPolicy = new resilience_1.RetryPolicy(config.retry);
|
|
25
|
+
}
|
|
26
|
+
if (config.circuitBreaker) {
|
|
27
|
+
const breakerName = `gateway:${config.serviceName}`;
|
|
28
|
+
this.circuitBreaker = resilience_1.CircuitBreakerRegistry.getOrCreate(breakerName, config.circuitBreaker);
|
|
29
|
+
}
|
|
30
|
+
if (config.timeout) {
|
|
31
|
+
this.timeout = new resilience_1.Timeout(config.timeout);
|
|
32
|
+
}
|
|
33
|
+
if (config.rateLimit) {
|
|
34
|
+
this.rateLimiter = new resilience_1.RateLimiter(config.rateLimit);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Forward an incoming request to the target service
|
|
39
|
+
*/
|
|
40
|
+
async forward(request) {
|
|
41
|
+
// Apply rate limiting — reject with 429 when limit exceeded
|
|
42
|
+
if (this.rateLimiter) {
|
|
43
|
+
if (!this.rateLimiter.tryAcquire()) {
|
|
44
|
+
const retryAfterMs = this.rateLimiter.getRetryAfterMs();
|
|
45
|
+
throw new resilience_1.RateLimitError(`Rate limit exceeded for ${this.config.serviceName}. Retry after ${retryAfterMs}ms`, retryAfterMs);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Apply request transformation
|
|
49
|
+
let transformedRequest = request;
|
|
50
|
+
if (this.config.transform?.request) {
|
|
51
|
+
transformedRequest = this.config.transform.request(request);
|
|
52
|
+
}
|
|
53
|
+
// Build the execution function
|
|
54
|
+
const executeFn = async () => {
|
|
55
|
+
return this.doForward(transformedRequest);
|
|
56
|
+
};
|
|
57
|
+
// Wrap with resilience layers
|
|
58
|
+
let wrappedFn = executeFn;
|
|
59
|
+
// Retry wraps the inner call
|
|
60
|
+
if (this.retryPolicy) {
|
|
61
|
+
const retryPolicy = this.retryPolicy;
|
|
62
|
+
const innerFn = wrappedFn;
|
|
63
|
+
wrappedFn = () => retryPolicy.execute(innerFn);
|
|
64
|
+
}
|
|
65
|
+
// Circuit breaker wraps retry
|
|
66
|
+
if (this.circuitBreaker) {
|
|
67
|
+
const breaker = this.circuitBreaker;
|
|
68
|
+
const innerFn = wrappedFn;
|
|
69
|
+
wrappedFn = () => breaker.execute(innerFn);
|
|
70
|
+
}
|
|
71
|
+
// Timeout wraps circuit breaker
|
|
72
|
+
if (this.timeout) {
|
|
73
|
+
const timeout = this.timeout;
|
|
74
|
+
const innerFn = wrappedFn;
|
|
75
|
+
wrappedFn = () => timeout.execute(innerFn);
|
|
76
|
+
}
|
|
77
|
+
const startTime = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
const response = await wrappedFn();
|
|
80
|
+
this.metrics.recordSuccess(Date.now() - startTime);
|
|
81
|
+
// Apply response transformation
|
|
82
|
+
if (this.config.transform?.response) {
|
|
83
|
+
return this.config.transform.response(response);
|
|
84
|
+
}
|
|
85
|
+
return response;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
this.metrics.recordFailure(Date.now() - startTime, String(error));
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Forward a request with a specific version filter
|
|
94
|
+
*/
|
|
95
|
+
async forwardToVersion(request, version, additionalFilter) {
|
|
96
|
+
const filter = {
|
|
97
|
+
...this.config.filter,
|
|
98
|
+
...additionalFilter,
|
|
99
|
+
metadata: {
|
|
100
|
+
...this.config.filter?.metadata,
|
|
101
|
+
...additionalFilter?.metadata,
|
|
102
|
+
version,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
return this.forwardWithFilter(request, filter);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Forward with a custom filter override
|
|
109
|
+
*/
|
|
110
|
+
async forwardWithFilter(request, filter) {
|
|
111
|
+
const instance = await this.discoveryClient.getInstance(this.config.serviceName, this.config.loadBalancingStrategy, filter);
|
|
112
|
+
if (!instance) {
|
|
113
|
+
throw new Error(`No instances available for service: ${this.config.serviceName} with filter`);
|
|
114
|
+
}
|
|
115
|
+
return this.doForwardToInstance(request, instance);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get the metrics collector for this proxy
|
|
119
|
+
*/
|
|
120
|
+
getMetrics() {
|
|
121
|
+
return this.metrics;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get the circuit breaker instance (if configured)
|
|
125
|
+
*/
|
|
126
|
+
getCircuitBreaker() {
|
|
127
|
+
return this.circuitBreaker;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get the service name this proxy routes to
|
|
131
|
+
*/
|
|
132
|
+
getServiceName() {
|
|
133
|
+
return this.config.serviceName;
|
|
134
|
+
}
|
|
135
|
+
// ─── Internal ───
|
|
136
|
+
async doForward(request) {
|
|
137
|
+
const instance = await this.discoveryClient.getInstance(this.config.serviceName, this.config.loadBalancingStrategy, this.config.filter);
|
|
138
|
+
if (!instance) {
|
|
139
|
+
throw new Error(`No instances available for service: ${this.config.serviceName}`);
|
|
140
|
+
}
|
|
141
|
+
return this.doForwardToInstance(request, instance);
|
|
142
|
+
}
|
|
143
|
+
async doForwardToInstance(request, instance) {
|
|
144
|
+
// Build the target path
|
|
145
|
+
let targetPath = request.path;
|
|
146
|
+
if (this.config.stripPrefix && targetPath.startsWith(this.config.stripPrefix)) {
|
|
147
|
+
targetPath = targetPath.slice(this.config.stripPrefix.length) || '/';
|
|
148
|
+
}
|
|
149
|
+
if (this.config.addPrefix) {
|
|
150
|
+
targetPath = this.config.addPrefix + targetPath;
|
|
151
|
+
}
|
|
152
|
+
// Normalize: remove trailing slash so "/users/" becomes "/users".
|
|
153
|
+
// This avoids 404s when the backend registers routes without a trailing slash.
|
|
154
|
+
if (targetPath.length > 1 && targetPath.endsWith('/')) {
|
|
155
|
+
targetPath = targetPath.slice(0, -1);
|
|
156
|
+
}
|
|
157
|
+
const baseURL = `${instance.protocol || 'http'}://${instance.host}:${instance.port}`;
|
|
158
|
+
// Strip hop-by-hop headers that are specific to the client↔gateway connection.
|
|
159
|
+
// These must NOT be forwarded to the upstream service because:
|
|
160
|
+
// - content-length: the body is re-serialized by axios, so the original
|
|
161
|
+
// content-length would be wrong and the upstream would hang waiting for
|
|
162
|
+
// exactly that many bytes.
|
|
163
|
+
// - transfer-encoding: same issue — axios chooses its own encoding.
|
|
164
|
+
// - connection / keep-alive: per-hop, not end-to-end.
|
|
165
|
+
const HOP_BY_HOP = new Set([
|
|
166
|
+
'content-length',
|
|
167
|
+
'transfer-encoding',
|
|
168
|
+
'connection',
|
|
169
|
+
'keep-alive',
|
|
170
|
+
'upgrade',
|
|
171
|
+
'expect',
|
|
172
|
+
'host',
|
|
173
|
+
'te',
|
|
174
|
+
'trailer',
|
|
175
|
+
]);
|
|
176
|
+
const headers = {};
|
|
177
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
178
|
+
if (value !== undefined && !HOP_BY_HOP.has(key.toLowerCase())) {
|
|
179
|
+
headers[key] = Array.isArray(value) ? value.join(', ') : value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
headers['host'] = `${instance.host}:${instance.port}`;
|
|
183
|
+
const axiosConfig = {
|
|
184
|
+
method: request.method,
|
|
185
|
+
url: targetPath,
|
|
186
|
+
baseURL,
|
|
187
|
+
headers,
|
|
188
|
+
data: request.body,
|
|
189
|
+
params: request.query,
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const response = await this.axiosInstance.request(axiosConfig);
|
|
193
|
+
return {
|
|
194
|
+
status: response.status,
|
|
195
|
+
headers: response.headers,
|
|
196
|
+
body: response.data,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (axios_1.default.isAxiosError(error) && error.response) {
|
|
201
|
+
return {
|
|
202
|
+
status: error.response.status,
|
|
203
|
+
headers: error.response.headers,
|
|
204
|
+
body: error.response.data,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
exports.ServiceProxy = ServiceProxy;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route Matcher
|
|
3
|
+
* Matches incoming request paths against gateway route patterns.
|
|
4
|
+
* Supports glob patterns (/**), path parameters (:param), and wildcards (*).
|
|
5
|
+
*/
|
|
6
|
+
export interface RouteMatch {
|
|
7
|
+
/** Whether the pattern matched */
|
|
8
|
+
matched: boolean;
|
|
9
|
+
/** Extracted path parameters */
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
/** The remaining path after the matched prefix (for proxying) */
|
|
12
|
+
remainingPath: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Convert a route pattern to a regex for matching
|
|
16
|
+
*
|
|
17
|
+
* Patterns:
|
|
18
|
+
* /api/users -> exact match
|
|
19
|
+
* /api/users/:id -> path parameter
|
|
20
|
+
* /api/users/* -> single segment wildcard
|
|
21
|
+
* /api/users/** -> multi-segment wildcard (greedy)
|
|
22
|
+
*/
|
|
23
|
+
export declare function matchRoute(pattern: string, path: string): RouteMatch;
|
|
24
|
+
/**
|
|
25
|
+
* Sort route patterns by specificity (most specific first)
|
|
26
|
+
* Rules:
|
|
27
|
+
* 1. Exact paths > parameterized paths > wildcards > catch-all
|
|
28
|
+
* 2. Longer paths > shorter paths
|
|
29
|
+
*/
|
|
30
|
+
export declare function sortRoutesBySpecificity(patterns: string[]): string[];
|
|
31
|
+
//# sourceMappingURL=route-matcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../../src/routing/route-matcher.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,UAAU;IACzB,kCAAkC;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,iEAAiE;IACjE,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,CAoDpE;AAgBD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAOpE"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Route Matcher
|
|
4
|
+
* Matches incoming request paths against gateway route patterns.
|
|
5
|
+
* Supports glob patterns (/**), path parameters (:param), and wildcards (*).
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.matchRoute = matchRoute;
|
|
9
|
+
exports.sortRoutesBySpecificity = sortRoutesBySpecificity;
|
|
10
|
+
/**
|
|
11
|
+
* Convert a route pattern to a regex for matching
|
|
12
|
+
*
|
|
13
|
+
* Patterns:
|
|
14
|
+
* /api/users -> exact match
|
|
15
|
+
* /api/users/:id -> path parameter
|
|
16
|
+
* /api/users/* -> single segment wildcard
|
|
17
|
+
* /api/users/** -> multi-segment wildcard (greedy)
|
|
18
|
+
*/
|
|
19
|
+
function matchRoute(pattern, path) {
|
|
20
|
+
const noMatch = { matched: false, params: {}, remainingPath: '' };
|
|
21
|
+
// Normalize paths
|
|
22
|
+
const normPattern = normalizePath(pattern);
|
|
23
|
+
const normPath = normalizePath(path);
|
|
24
|
+
// Handle exact "**" at end (catch-all)
|
|
25
|
+
if (normPattern.endsWith('/**')) {
|
|
26
|
+
const prefix = normPattern.slice(0, -3); // Remove /**
|
|
27
|
+
if (normPath === prefix || normPath.startsWith(prefix + '/')) {
|
|
28
|
+
const remaining = normPath.slice(prefix.length) || '/';
|
|
29
|
+
return {
|
|
30
|
+
matched: true,
|
|
31
|
+
params: {},
|
|
32
|
+
remainingPath: remaining,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return noMatch;
|
|
36
|
+
}
|
|
37
|
+
// Split into segments
|
|
38
|
+
const patternSegments = normPattern.split('/').filter(Boolean);
|
|
39
|
+
const pathSegments = normPath.split('/').filter(Boolean);
|
|
40
|
+
// Different segment counts means no match (unless there's a wildcard)
|
|
41
|
+
if (patternSegments.length !== pathSegments.length) {
|
|
42
|
+
return noMatch;
|
|
43
|
+
}
|
|
44
|
+
const params = {};
|
|
45
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
46
|
+
const pSeg = patternSegments[i];
|
|
47
|
+
const rSeg = pathSegments[i];
|
|
48
|
+
if (pSeg.startsWith(':')) {
|
|
49
|
+
// Path parameter
|
|
50
|
+
params[pSeg.slice(1)] = rSeg;
|
|
51
|
+
}
|
|
52
|
+
else if (pSeg === '*') {
|
|
53
|
+
// Single-segment wildcard, matches anything
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
else if (pSeg !== rSeg) {
|
|
57
|
+
return noMatch;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
matched: true,
|
|
62
|
+
params,
|
|
63
|
+
remainingPath: normPath,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Normalize a path: ensure leading slash, remove trailing slash
|
|
68
|
+
*/
|
|
69
|
+
function normalizePath(path) {
|
|
70
|
+
let normalized = path;
|
|
71
|
+
if (!normalized.startsWith('/')) {
|
|
72
|
+
normalized = '/' + normalized;
|
|
73
|
+
}
|
|
74
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
75
|
+
normalized = normalized.slice(0, -1);
|
|
76
|
+
}
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sort route patterns by specificity (most specific first)
|
|
81
|
+
* Rules:
|
|
82
|
+
* 1. Exact paths > parameterized paths > wildcards > catch-all
|
|
83
|
+
* 2. Longer paths > shorter paths
|
|
84
|
+
*/
|
|
85
|
+
function sortRoutesBySpecificity(patterns) {
|
|
86
|
+
return [...patterns].sort((a, b) => {
|
|
87
|
+
const aScore = getSpecificityScore(a);
|
|
88
|
+
const bScore = getSpecificityScore(b);
|
|
89
|
+
if (aScore !== bScore)
|
|
90
|
+
return bScore - aScore; // Higher score = more specific
|
|
91
|
+
return b.length - a.length; // Longer = more specific
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function getSpecificityScore(pattern) {
|
|
95
|
+
const segments = pattern.split('/').filter(Boolean);
|
|
96
|
+
let score = segments.length * 10; // Base score from segment count
|
|
97
|
+
for (const seg of segments) {
|
|
98
|
+
if (seg === '**') {
|
|
99
|
+
score -= 5; // Catch-all is least specific
|
|
100
|
+
}
|
|
101
|
+
else if (seg === '*') {
|
|
102
|
+
score -= 3; // Single wildcard
|
|
103
|
+
}
|
|
104
|
+
else if (seg.startsWith(':')) {
|
|
105
|
+
score -= 1; // Parameter
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
score += 2; // Exact segment is most specific
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return score;
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version Router
|
|
3
|
+
* Routes requests to specific service versions based on:
|
|
4
|
+
* - Header (X-API-Version)
|
|
5
|
+
* - URI prefix (/v2/api/...)
|
|
6
|
+
* - Query parameter (?version=v2)
|
|
7
|
+
* - Weighted random (percentage-based)
|
|
8
|
+
*/
|
|
9
|
+
import { VersionRouteConfig, VersionRouteEntry, ProxyRequest } from '../types';
|
|
10
|
+
export interface VersionResolution {
|
|
11
|
+
/** The resolved version to route to */
|
|
12
|
+
version: string;
|
|
13
|
+
/** How the version was resolved */
|
|
14
|
+
resolvedBy: 'header' | 'uri' | 'query' | 'weight' | 'default';
|
|
15
|
+
}
|
|
16
|
+
export declare class VersionRouter {
|
|
17
|
+
private config;
|
|
18
|
+
constructor(config: VersionRouteConfig);
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the target version for an incoming request
|
|
21
|
+
*/
|
|
22
|
+
resolve(request: ProxyRequest): VersionResolution;
|
|
23
|
+
/**
|
|
24
|
+
* Get the version configuration for a specific version
|
|
25
|
+
*/
|
|
26
|
+
getVersionEntry(version: string): VersionRouteEntry | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Get all configured versions
|
|
29
|
+
*/
|
|
30
|
+
getVersions(): string[];
|
|
31
|
+
private resolveFromHeader;
|
|
32
|
+
private resolveFromUri;
|
|
33
|
+
private resolveFromQuery;
|
|
34
|
+
private resolveByWeight;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=version-router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version-router.d.ts","sourceRoot":"","sources":["../../src/routing/version-router.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAE/E,MAAM,WAAW,iBAAiB;IAChC,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,UAAU,EAAE,QAAQ,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;CAC/D;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAqB;gBAEvB,MAAM,EAAE,kBAAkB;IAStC;;OAEG;IACH,OAAO,CAAC,OAAO,EAAE,YAAY,GAAG,iBAAiB;IAuCjD;;OAEG;IACH,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS;IAI/D;;OAEG;IACH,WAAW,IAAI,MAAM,EAAE;IAMvB,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,eAAe;CA6BxB"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Version Router
|
|
4
|
+
* Routes requests to specific service versions based on:
|
|
5
|
+
* - Header (X-API-Version)
|
|
6
|
+
* - URI prefix (/v2/api/...)
|
|
7
|
+
* - Query parameter (?version=v2)
|
|
8
|
+
* - Weighted random (percentage-based)
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.VersionRouter = void 0;
|
|
12
|
+
class VersionRouter {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = {
|
|
15
|
+
strategy: config.strategy ?? 'header',
|
|
16
|
+
header: config.header ?? 'X-API-Version',
|
|
17
|
+
queryParam: config.queryParam ?? 'version',
|
|
18
|
+
...config,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the target version for an incoming request
|
|
23
|
+
*/
|
|
24
|
+
resolve(request) {
|
|
25
|
+
// 1. Try explicit version from header
|
|
26
|
+
if (this.config.strategy === 'header' || !this.config.strategy) {
|
|
27
|
+
const headerVersion = this.resolveFromHeader(request);
|
|
28
|
+
if (headerVersion)
|
|
29
|
+
return headerVersion;
|
|
30
|
+
}
|
|
31
|
+
// 2. Try explicit version from URI
|
|
32
|
+
if (this.config.strategy === 'uri') {
|
|
33
|
+
const uriVersion = this.resolveFromUri(request);
|
|
34
|
+
if (uriVersion)
|
|
35
|
+
return uriVersion;
|
|
36
|
+
}
|
|
37
|
+
// 3. Try explicit version from query
|
|
38
|
+
if (this.config.strategy === 'query') {
|
|
39
|
+
const queryVersion = this.resolveFromQuery(request);
|
|
40
|
+
if (queryVersion)
|
|
41
|
+
return queryVersion;
|
|
42
|
+
}
|
|
43
|
+
// 4. Fall back to weighted selection
|
|
44
|
+
const weightedVersion = this.resolveByWeight();
|
|
45
|
+
if (weightedVersion)
|
|
46
|
+
return weightedVersion;
|
|
47
|
+
// 5. Fall back to default version
|
|
48
|
+
if (this.config.defaultVersion) {
|
|
49
|
+
return {
|
|
50
|
+
version: this.config.defaultVersion,
|
|
51
|
+
resolvedBy: 'default',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// 6. Use the first defined version
|
|
55
|
+
const firstVersion = Object.keys(this.config.routes)[0];
|
|
56
|
+
return {
|
|
57
|
+
version: firstVersion,
|
|
58
|
+
resolvedBy: 'default',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Get the version configuration for a specific version
|
|
63
|
+
*/
|
|
64
|
+
getVersionEntry(version) {
|
|
65
|
+
return this.config.routes[version];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get all configured versions
|
|
69
|
+
*/
|
|
70
|
+
getVersions() {
|
|
71
|
+
return Object.keys(this.config.routes);
|
|
72
|
+
}
|
|
73
|
+
// ─── Resolution Strategies ───
|
|
74
|
+
resolveFromHeader(request) {
|
|
75
|
+
const headerName = this.config.header;
|
|
76
|
+
const headerValue = request.headers[headerName] || request.headers[headerName.toLowerCase()];
|
|
77
|
+
if (headerValue && typeof headerValue === 'string') {
|
|
78
|
+
const version = headerValue.trim();
|
|
79
|
+
const entry = this.config.routes[version];
|
|
80
|
+
if (entry && (entry.weight > 0 || entry.allowExplicit)) {
|
|
81
|
+
return { version, resolvedBy: 'header' };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
resolveFromUri(request) {
|
|
87
|
+
// Extract version from URI like /v2/api/users -> v2
|
|
88
|
+
const match = request.path.match(/^\/(v\d+)(\/.*)?$/);
|
|
89
|
+
if (match) {
|
|
90
|
+
const version = match[1];
|
|
91
|
+
const entry = this.config.routes[version];
|
|
92
|
+
if (entry && (entry.weight > 0 || entry.allowExplicit)) {
|
|
93
|
+
return { version, resolvedBy: 'uri' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
resolveFromQuery(request) {
|
|
99
|
+
const paramName = this.config.queryParam;
|
|
100
|
+
const version = request.query?.[paramName];
|
|
101
|
+
if (version) {
|
|
102
|
+
const entry = this.config.routes[version];
|
|
103
|
+
if (entry && (entry.weight > 0 || entry.allowExplicit)) {
|
|
104
|
+
return { version, resolvedBy: 'query' };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
resolveByWeight() {
|
|
110
|
+
// Collect versions with weight > 0
|
|
111
|
+
const weighted = [];
|
|
112
|
+
let totalWeight = 0;
|
|
113
|
+
for (const [version, entry] of Object.entries(this.config.routes)) {
|
|
114
|
+
if (entry.weight > 0) {
|
|
115
|
+
weighted.push({ version, weight: entry.weight });
|
|
116
|
+
totalWeight += entry.weight;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (weighted.length === 0 || totalWeight === 0)
|
|
120
|
+
return null;
|
|
121
|
+
// Weighted random selection
|
|
122
|
+
let random = Math.random() * totalWeight;
|
|
123
|
+
for (const item of weighted) {
|
|
124
|
+
random -= item.weight;
|
|
125
|
+
if (random <= 0) {
|
|
126
|
+
return { version: item.version, resolvedBy: 'weight' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Fallback (shouldn't reach here due to float precision)
|
|
130
|
+
return {
|
|
131
|
+
version: weighted[weighted.length - 1].version,
|
|
132
|
+
resolvedBy: 'weight',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.VersionRouter = VersionRouter;
|