@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,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const version_router_1 = require("../routing/version-router");
|
|
4
|
+
function makeRequest(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
method: 'GET',
|
|
7
|
+
path: '/api/test',
|
|
8
|
+
headers: {},
|
|
9
|
+
...overrides,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('VersionRouter', () => {
|
|
13
|
+
describe('header-based routing', () => {
|
|
14
|
+
it('should resolve version from header', () => {
|
|
15
|
+
const router = new version_router_1.VersionRouter({
|
|
16
|
+
header: 'X-API-Version',
|
|
17
|
+
routes: {
|
|
18
|
+
v1: { weight: 80 },
|
|
19
|
+
v2: { weight: 20 },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const result = router.resolve(makeRequest({ headers: { 'X-API-Version': 'v2' } }));
|
|
23
|
+
expect(result.version).toBe('v2');
|
|
24
|
+
expect(result.resolvedBy).toBe('header');
|
|
25
|
+
});
|
|
26
|
+
it('should resolve explicitly allowed version even with 0 weight', () => {
|
|
27
|
+
const router = new version_router_1.VersionRouter({
|
|
28
|
+
header: 'X-API-Version',
|
|
29
|
+
routes: {
|
|
30
|
+
v1: { weight: 100 },
|
|
31
|
+
v2: { weight: 0, allowExplicit: true },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const result = router.resolve(makeRequest({ headers: { 'X-API-Version': 'v2' } }));
|
|
35
|
+
expect(result.version).toBe('v2');
|
|
36
|
+
expect(result.resolvedBy).toBe('header');
|
|
37
|
+
});
|
|
38
|
+
it('should not resolve version with 0 weight and no allowExplicit', () => {
|
|
39
|
+
const router = new version_router_1.VersionRouter({
|
|
40
|
+
header: 'X-API-Version',
|
|
41
|
+
routes: {
|
|
42
|
+
v1: { weight: 100 },
|
|
43
|
+
v2: { weight: 0 },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
// Header says v2 but it's not explicitly allowed
|
|
47
|
+
const result = router.resolve(makeRequest({ headers: { 'X-API-Version': 'v2' } }));
|
|
48
|
+
expect(result.version).toBe('v1');
|
|
49
|
+
expect(result.resolvedBy).toBe('weight');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('weighted routing', () => {
|
|
53
|
+
it('should fall back to weighted routing when no explicit version', () => {
|
|
54
|
+
const router = new version_router_1.VersionRouter({
|
|
55
|
+
routes: {
|
|
56
|
+
v1: { weight: 100 },
|
|
57
|
+
v2: { weight: 0 },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const result = router.resolve(makeRequest());
|
|
61
|
+
expect(result.version).toBe('v1');
|
|
62
|
+
expect(result.resolvedBy).toBe('weight');
|
|
63
|
+
});
|
|
64
|
+
it('should distribute based on weights', () => {
|
|
65
|
+
const router = new version_router_1.VersionRouter({
|
|
66
|
+
routes: {
|
|
67
|
+
v1: { weight: 50 },
|
|
68
|
+
v2: { weight: 50 },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const counts = { v1: 0, v2: 0 };
|
|
72
|
+
for (let i = 0; i < 1000; i++) {
|
|
73
|
+
const result = router.resolve(makeRequest());
|
|
74
|
+
counts[result.version]++;
|
|
75
|
+
}
|
|
76
|
+
// With 50/50 weight over 1000 runs, each should be roughly 500
|
|
77
|
+
expect(counts['v1']).toBeGreaterThan(350);
|
|
78
|
+
expect(counts['v2']).toBeGreaterThan(350);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('default version', () => {
|
|
82
|
+
it('should use defaultVersion when no other resolution works', () => {
|
|
83
|
+
const router = new version_router_1.VersionRouter({
|
|
84
|
+
defaultVersion: 'v1',
|
|
85
|
+
routes: {
|
|
86
|
+
v1: { weight: 0 },
|
|
87
|
+
v2: { weight: 0 },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const result = router.resolve(makeRequest());
|
|
91
|
+
expect(result.version).toBe('v1');
|
|
92
|
+
expect(result.resolvedBy).toBe('default');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('URI-based routing', () => {
|
|
96
|
+
it('should resolve version from URI path', () => {
|
|
97
|
+
const router = new version_router_1.VersionRouter({
|
|
98
|
+
strategy: 'uri',
|
|
99
|
+
routes: {
|
|
100
|
+
v1: { weight: 100 },
|
|
101
|
+
v2: { weight: 0, allowExplicit: true },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const result = router.resolve(makeRequest({ path: '/v2/api/test' }));
|
|
105
|
+
expect(result.version).toBe('v2');
|
|
106
|
+
expect(result.resolvedBy).toBe('uri');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('query-based routing', () => {
|
|
110
|
+
it('should resolve version from query parameter', () => {
|
|
111
|
+
const router = new version_router_1.VersionRouter({
|
|
112
|
+
strategy: 'query',
|
|
113
|
+
queryParam: 'version',
|
|
114
|
+
routes: {
|
|
115
|
+
v1: { weight: 100 },
|
|
116
|
+
v2: { weight: 0, allowExplicit: true },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const result = router.resolve(makeRequest({ query: { version: 'v2' } }));
|
|
120
|
+
expect(result.version).toBe('v2');
|
|
121
|
+
expect(result.resolvedBy).toBe('query');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('getVersions', () => {
|
|
125
|
+
it('should return all configured versions', () => {
|
|
126
|
+
const router = new version_router_1.VersionRouter({
|
|
127
|
+
routes: {
|
|
128
|
+
v1: { weight: 50 },
|
|
129
|
+
v2: { weight: 50 },
|
|
130
|
+
v3: { weight: 0 },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
expect(router.getVersions()).toEqual(['v1', 'v2', 'v3']);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canary Deployment Engine
|
|
3
|
+
*
|
|
4
|
+
* The differentiator feature of @hazeljs/gateway.
|
|
5
|
+
*
|
|
6
|
+
* Tracks real-time error rates and latency per service version,
|
|
7
|
+
* automatically promotes canary traffic through configurable steps,
|
|
8
|
+
* and rolls back if error thresholds are breached.
|
|
9
|
+
*
|
|
10
|
+
* States:
|
|
11
|
+
* ACTIVE -> canary is receiving traffic at current weight
|
|
12
|
+
* PROMOTED -> canary promoted to 100%, rollout complete
|
|
13
|
+
* ROLLED_BACK -> canary failed, all traffic to stable
|
|
14
|
+
* PAUSED -> manual pause, no automatic transitions
|
|
15
|
+
*/
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
17
|
+
import { CanaryConfig, CanaryState, CanaryMetrics, ProxyRequest } from '../types';
|
|
18
|
+
export interface CanaryStatus {
|
|
19
|
+
state: CanaryState;
|
|
20
|
+
stableVersion: string;
|
|
21
|
+
canaryVersion: string;
|
|
22
|
+
currentStableWeight: number;
|
|
23
|
+
currentCanaryWeight: number;
|
|
24
|
+
currentStep: number;
|
|
25
|
+
totalSteps: number;
|
|
26
|
+
metrics: CanaryMetrics;
|
|
27
|
+
lastEvaluation?: Date;
|
|
28
|
+
lastTransition?: Date;
|
|
29
|
+
}
|
|
30
|
+
export declare class CanaryEngine extends EventEmitter {
|
|
31
|
+
private state;
|
|
32
|
+
private config;
|
|
33
|
+
private stableMetrics;
|
|
34
|
+
private canaryMetrics;
|
|
35
|
+
private currentStableWeight;
|
|
36
|
+
private currentCanaryWeight;
|
|
37
|
+
private currentStepIndex;
|
|
38
|
+
private evaluationTimer?;
|
|
39
|
+
private promotionTimer?;
|
|
40
|
+
private lastEvaluation?;
|
|
41
|
+
private lastTransition?;
|
|
42
|
+
private evaluationWindowMs;
|
|
43
|
+
private stepIntervalMs;
|
|
44
|
+
constructor(config: CanaryConfig);
|
|
45
|
+
/**
|
|
46
|
+
* Start the canary evaluation loop
|
|
47
|
+
*/
|
|
48
|
+
start(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Stop the canary evaluation loop
|
|
51
|
+
*/
|
|
52
|
+
stop(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Route a request: returns 'stable' or 'canary' based on current weights
|
|
55
|
+
*/
|
|
56
|
+
selectVersion(_request: ProxyRequest): 'stable' | 'canary';
|
|
57
|
+
/**
|
|
58
|
+
* Record a successful request for a version
|
|
59
|
+
*/
|
|
60
|
+
recordSuccess(target: 'stable' | 'canary', duration: number): void;
|
|
61
|
+
/**
|
|
62
|
+
* Record a failed request for a version
|
|
63
|
+
*/
|
|
64
|
+
recordFailure(target: 'stable' | 'canary', duration: number, error?: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* Get the current canary status
|
|
67
|
+
*/
|
|
68
|
+
getStatus(): CanaryStatus;
|
|
69
|
+
/**
|
|
70
|
+
* Get current metrics for both versions
|
|
71
|
+
*/
|
|
72
|
+
getMetrics(): CanaryMetrics;
|
|
73
|
+
/**
|
|
74
|
+
* Get the version string for a target
|
|
75
|
+
*/
|
|
76
|
+
getVersion(target: 'stable' | 'canary'): string;
|
|
77
|
+
/**
|
|
78
|
+
* Manually promote to the next step
|
|
79
|
+
*/
|
|
80
|
+
promote(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Manually rollback the canary
|
|
83
|
+
*/
|
|
84
|
+
rollback(): void;
|
|
85
|
+
/**
|
|
86
|
+
* Manually pause the canary
|
|
87
|
+
*/
|
|
88
|
+
pause(): void;
|
|
89
|
+
/**
|
|
90
|
+
* Resume a paused canary
|
|
91
|
+
*/
|
|
92
|
+
resume(): void;
|
|
93
|
+
private evaluate;
|
|
94
|
+
private makeDecision;
|
|
95
|
+
private evaluateByErrorRate;
|
|
96
|
+
private evaluateByLatency;
|
|
97
|
+
private schedulePromotion;
|
|
98
|
+
private doPromote;
|
|
99
|
+
private doRollback;
|
|
100
|
+
private findStepIndex;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Parse an interval string like '5m', '10s', '1h' to milliseconds.
|
|
104
|
+
* Also accepts raw numbers (treated as ms).
|
|
105
|
+
*/
|
|
106
|
+
export declare function parseInterval(value: number | string): number;
|
|
107
|
+
//# sourceMappingURL=canary-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canary-engine.d.ts","sourceRoot":"","sources":["../../src/canary/canary-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAkB,YAAY,EAAE,MAAM,UAAU,CAAC;AAElG,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,WAAW,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,aAAa,CAAC;IACvB,cAAc,CAAC,EAAE,IAAI,CAAC;IACtB,cAAc,CAAC,EAAE,IAAI,CAAC;CACvB;AAED,qBAAa,YAAa,SAAQ,YAAY;IAC5C,OAAO,CAAC,KAAK,CAAmC;IAChD,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,aAAa,CAAmB;IACxC,OAAO,CAAC,aAAa,CAAmB;IACxC,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,eAAe,CAAC,CAAiB;IACzC,OAAO,CAAC,cAAc,CAAC,CAAiB;IACxC,OAAO,CAAC,cAAc,CAAC,CAAO;IAC9B,OAAO,CAAC,cAAc,CAAC,CAAO;IAC9B,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,cAAc,CAAS;gBAEnB,MAAM,EAAE,YAAY;IAiBhC;;OAEG;IACH,KAAK,IAAI,IAAI;IAeb;;OAEG;IACH,IAAI,IAAI,IAAI;IAWZ;;OAEG;IACH,aAAa,CAAC,QAAQ,EAAE,YAAY,GAAG,QAAQ,GAAG,QAAQ;IAS1D;;OAEG;IACH,aAAa,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAQlE;;OAEG;IACH,aAAa,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI;IAQlF;;OAEG;IACH,SAAS,IAAI,YAAY;IAezB;;OAEG;IACH,UAAU,IAAI,aAAa;IAsB3B;;OAEG;IACH,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,MAAM;IAI/C;;OAEG;IACH,OAAO,IAAI,IAAI;IAIf;;OAEG;IACH,QAAQ,IAAI,IAAI;IAIhB;;OAEG;IACH,KAAK,IAAI,IAAI;IAMb;;OAEG;IACH,MAAM,IAAI,IAAI;IASd,OAAO,CAAC,QAAQ;IA+BhB,OAAO,CAAC,YAAY;IAmBpB,OAAO,CAAC,mBAAmB;IAgB3B,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,iBAAiB;IAiBzB,OAAO,CAAC,SAAS;IAkCjB,OAAO,CAAC,UAAU;IAelB,OAAO,CAAC,aAAa;CAOtB;AAID;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAyB5D"}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Canary Deployment Engine
|
|
4
|
+
*
|
|
5
|
+
* The differentiator feature of @hazeljs/gateway.
|
|
6
|
+
*
|
|
7
|
+
* Tracks real-time error rates and latency per service version,
|
|
8
|
+
* automatically promotes canary traffic through configurable steps,
|
|
9
|
+
* and rolls back if error thresholds are breached.
|
|
10
|
+
*
|
|
11
|
+
* States:
|
|
12
|
+
* ACTIVE -> canary is receiving traffic at current weight
|
|
13
|
+
* PROMOTED -> canary promoted to 100%, rollout complete
|
|
14
|
+
* ROLLED_BACK -> canary failed, all traffic to stable
|
|
15
|
+
* PAUSED -> manual pause, no automatic transitions
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.CanaryEngine = void 0;
|
|
19
|
+
exports.parseInterval = parseInterval;
|
|
20
|
+
const events_1 = require("events");
|
|
21
|
+
const resilience_1 = require("@hazeljs/resilience");
|
|
22
|
+
const types_1 = require("../types");
|
|
23
|
+
class CanaryEngine extends events_1.EventEmitter {
|
|
24
|
+
constructor(config) {
|
|
25
|
+
super();
|
|
26
|
+
this.state = types_1.CanaryState.ACTIVE;
|
|
27
|
+
this.currentStepIndex = 0;
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.currentStableWeight = config.stable.weight;
|
|
30
|
+
this.currentCanaryWeight = config.canary.weight;
|
|
31
|
+
this.evaluationWindowMs = parseInterval(config.promotion.evaluationWindow);
|
|
32
|
+
this.stepIntervalMs = parseInterval(config.promotion.stepInterval);
|
|
33
|
+
// Initialize metrics collectors with evaluation window
|
|
34
|
+
this.stableMetrics = new resilience_1.MetricsCollector(this.evaluationWindowMs);
|
|
35
|
+
this.canaryMetrics = new resilience_1.MetricsCollector(this.evaluationWindowMs);
|
|
36
|
+
// Find the initial step index based on current canary weight
|
|
37
|
+
this.currentStepIndex = this.findStepIndex(config.canary.weight);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Start the canary evaluation loop
|
|
41
|
+
*/
|
|
42
|
+
start() {
|
|
43
|
+
if (this.state !== types_1.CanaryState.ACTIVE)
|
|
44
|
+
return;
|
|
45
|
+
// Schedule periodic evaluation
|
|
46
|
+
this.evaluationTimer = setInterval(() => {
|
|
47
|
+
this.evaluate();
|
|
48
|
+
}, this.evaluationWindowMs);
|
|
49
|
+
this.emit('canary:started', {
|
|
50
|
+
stable: this.config.stable.version,
|
|
51
|
+
canary: this.config.canary.version,
|
|
52
|
+
weight: this.currentCanaryWeight,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Stop the canary evaluation loop
|
|
57
|
+
*/
|
|
58
|
+
stop() {
|
|
59
|
+
if (this.evaluationTimer) {
|
|
60
|
+
clearInterval(this.evaluationTimer);
|
|
61
|
+
this.evaluationTimer = undefined;
|
|
62
|
+
}
|
|
63
|
+
if (this.promotionTimer) {
|
|
64
|
+
clearTimeout(this.promotionTimer);
|
|
65
|
+
this.promotionTimer = undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Route a request: returns 'stable' or 'canary' based on current weights
|
|
70
|
+
*/
|
|
71
|
+
selectVersion(_request) {
|
|
72
|
+
if (this.state === types_1.CanaryState.ROLLED_BACK)
|
|
73
|
+
return 'stable';
|
|
74
|
+
if (this.state === types_1.CanaryState.PROMOTED)
|
|
75
|
+
return 'canary';
|
|
76
|
+
// Weighted random selection
|
|
77
|
+
const random = Math.random() * 100;
|
|
78
|
+
return random < this.currentCanaryWeight ? 'canary' : 'stable';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Record a successful request for a version
|
|
82
|
+
*/
|
|
83
|
+
recordSuccess(target, duration) {
|
|
84
|
+
if (target === 'stable') {
|
|
85
|
+
this.stableMetrics.recordSuccess(duration);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.canaryMetrics.recordSuccess(duration);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Record a failed request for a version
|
|
93
|
+
*/
|
|
94
|
+
recordFailure(target, duration, error) {
|
|
95
|
+
if (target === 'stable') {
|
|
96
|
+
this.stableMetrics.recordFailure(duration, error);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
this.canaryMetrics.recordFailure(duration, error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get the current canary status
|
|
104
|
+
*/
|
|
105
|
+
getStatus() {
|
|
106
|
+
return {
|
|
107
|
+
state: this.state,
|
|
108
|
+
stableVersion: this.config.stable.version,
|
|
109
|
+
canaryVersion: this.config.canary.version,
|
|
110
|
+
currentStableWeight: this.currentStableWeight,
|
|
111
|
+
currentCanaryWeight: this.currentCanaryWeight,
|
|
112
|
+
currentStep: this.currentStepIndex,
|
|
113
|
+
totalSteps: this.config.promotion.steps.length,
|
|
114
|
+
metrics: this.getMetrics(),
|
|
115
|
+
lastEvaluation: this.lastEvaluation,
|
|
116
|
+
lastTransition: this.lastTransition,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get current metrics for both versions
|
|
121
|
+
*/
|
|
122
|
+
getMetrics() {
|
|
123
|
+
const stableSnapshot = this.stableMetrics.getSnapshot();
|
|
124
|
+
const canarySnapshot = this.canaryMetrics.getSnapshot();
|
|
125
|
+
return {
|
|
126
|
+
stable: {
|
|
127
|
+
totalRequests: stableSnapshot.totalCalls,
|
|
128
|
+
errorCount: stableSnapshot.failureCalls,
|
|
129
|
+
errorRate: stableSnapshot.failureRate,
|
|
130
|
+
averageLatency: stableSnapshot.averageResponseTime,
|
|
131
|
+
p99Latency: stableSnapshot.p99ResponseTime,
|
|
132
|
+
},
|
|
133
|
+
canary: {
|
|
134
|
+
totalRequests: canarySnapshot.totalCalls,
|
|
135
|
+
errorCount: canarySnapshot.failureCalls,
|
|
136
|
+
errorRate: canarySnapshot.failureRate,
|
|
137
|
+
averageLatency: canarySnapshot.averageResponseTime,
|
|
138
|
+
p99Latency: canarySnapshot.p99ResponseTime,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get the version string for a target
|
|
144
|
+
*/
|
|
145
|
+
getVersion(target) {
|
|
146
|
+
return target === 'stable' ? this.config.stable.version : this.config.canary.version;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Manually promote to the next step
|
|
150
|
+
*/
|
|
151
|
+
promote() {
|
|
152
|
+
this.doPromote();
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Manually rollback the canary
|
|
156
|
+
*/
|
|
157
|
+
rollback() {
|
|
158
|
+
this.doRollback('manual');
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Manually pause the canary
|
|
162
|
+
*/
|
|
163
|
+
pause() {
|
|
164
|
+
this.state = types_1.CanaryState.PAUSED;
|
|
165
|
+
this.stop();
|
|
166
|
+
this.emit('canary:paused', this.getStatus());
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Resume a paused canary
|
|
170
|
+
*/
|
|
171
|
+
resume() {
|
|
172
|
+
if (this.state !== types_1.CanaryState.PAUSED)
|
|
173
|
+
return;
|
|
174
|
+
this.state = types_1.CanaryState.ACTIVE;
|
|
175
|
+
this.start();
|
|
176
|
+
this.emit('canary:resumed', this.getStatus());
|
|
177
|
+
}
|
|
178
|
+
// ─── Evaluation ───
|
|
179
|
+
evaluate() {
|
|
180
|
+
if (this.state !== types_1.CanaryState.ACTIVE)
|
|
181
|
+
return;
|
|
182
|
+
this.lastEvaluation = new Date();
|
|
183
|
+
const metrics = this.getMetrics();
|
|
184
|
+
// Check minimum requests threshold
|
|
185
|
+
const minRequests = this.config.promotion.minRequests ?? 10;
|
|
186
|
+
if (metrics.canary.totalRequests < minRequests) {
|
|
187
|
+
return; // Not enough data to evaluate
|
|
188
|
+
}
|
|
189
|
+
const decision = this.makeDecision(metrics);
|
|
190
|
+
switch (decision) {
|
|
191
|
+
case 'promote':
|
|
192
|
+
if (this.config.promotion.autoPromote) {
|
|
193
|
+
this.schedulePromotion();
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
case 'rollback':
|
|
197
|
+
if (this.config.promotion.autoRollback) {
|
|
198
|
+
this.doRollback('auto');
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'hold':
|
|
202
|
+
// Do nothing, wait for next evaluation
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
makeDecision(metrics) {
|
|
207
|
+
// Custom evaluator takes priority
|
|
208
|
+
if (this.config.promotion.customEvaluator) {
|
|
209
|
+
return this.config.promotion.customEvaluator(metrics);
|
|
210
|
+
}
|
|
211
|
+
const strategy = this.config.promotion.strategy;
|
|
212
|
+
if (strategy === 'error-rate') {
|
|
213
|
+
return this.evaluateByErrorRate(metrics);
|
|
214
|
+
}
|
|
215
|
+
if (strategy === 'latency') {
|
|
216
|
+
return this.evaluateByLatency(metrics);
|
|
217
|
+
}
|
|
218
|
+
return 'hold';
|
|
219
|
+
}
|
|
220
|
+
evaluateByErrorRate(metrics) {
|
|
221
|
+
const threshold = this.config.promotion.errorThreshold ?? 5;
|
|
222
|
+
// If canary error rate exceeds threshold, rollback
|
|
223
|
+
if (metrics.canary.errorRate > threshold) {
|
|
224
|
+
return 'rollback';
|
|
225
|
+
}
|
|
226
|
+
// If canary error rate is within threshold, promote
|
|
227
|
+
if (metrics.canary.errorRate <= threshold) {
|
|
228
|
+
return 'promote';
|
|
229
|
+
}
|
|
230
|
+
return 'hold';
|
|
231
|
+
}
|
|
232
|
+
evaluateByLatency(metrics) {
|
|
233
|
+
const threshold = this.config.promotion.latencyThreshold ?? 1000;
|
|
234
|
+
// If canary p99 latency exceeds threshold, rollback
|
|
235
|
+
if (metrics.canary.p99Latency > threshold) {
|
|
236
|
+
return 'rollback';
|
|
237
|
+
}
|
|
238
|
+
// If canary latency is acceptable, promote
|
|
239
|
+
return 'promote';
|
|
240
|
+
}
|
|
241
|
+
schedulePromotion() {
|
|
242
|
+
// If already at the last step, we're done
|
|
243
|
+
if (this.currentStepIndex >= this.config.promotion.steps.length - 1) {
|
|
244
|
+
this.doPromote();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// If a promotion is already scheduled, don't double-schedule
|
|
248
|
+
if (this.promotionTimer)
|
|
249
|
+
return;
|
|
250
|
+
// Schedule the next step
|
|
251
|
+
this.promotionTimer = setTimeout(() => {
|
|
252
|
+
this.promotionTimer = undefined;
|
|
253
|
+
this.doPromote();
|
|
254
|
+
}, this.stepIntervalMs);
|
|
255
|
+
}
|
|
256
|
+
doPromote() {
|
|
257
|
+
if (this.state !== types_1.CanaryState.ACTIVE)
|
|
258
|
+
return;
|
|
259
|
+
this.currentStepIndex++;
|
|
260
|
+
this.lastTransition = new Date();
|
|
261
|
+
if (this.currentStepIndex >= this.config.promotion.steps.length) {
|
|
262
|
+
// Fully promoted
|
|
263
|
+
this.currentCanaryWeight = 100;
|
|
264
|
+
this.currentStableWeight = 0;
|
|
265
|
+
this.state = types_1.CanaryState.PROMOTED;
|
|
266
|
+
this.stop();
|
|
267
|
+
this.emit('canary:complete', {
|
|
268
|
+
version: this.config.canary.version,
|
|
269
|
+
metrics: this.getMetrics(),
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Move to the next weight step
|
|
274
|
+
const newWeight = this.config.promotion.steps[this.currentStepIndex];
|
|
275
|
+
this.currentCanaryWeight = newWeight;
|
|
276
|
+
this.currentStableWeight = 100 - newWeight;
|
|
277
|
+
this.emit('canary:promote', {
|
|
278
|
+
step: this.currentStepIndex,
|
|
279
|
+
totalSteps: this.config.promotion.steps.length,
|
|
280
|
+
canaryWeight: this.currentCanaryWeight,
|
|
281
|
+
stableWeight: this.currentStableWeight,
|
|
282
|
+
metrics: this.getMetrics(),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
doRollback(trigger) {
|
|
286
|
+
this.state = types_1.CanaryState.ROLLED_BACK;
|
|
287
|
+
this.currentCanaryWeight = 0;
|
|
288
|
+
this.currentStableWeight = 100;
|
|
289
|
+
this.lastTransition = new Date();
|
|
290
|
+
this.stop();
|
|
291
|
+
this.emit('canary:rollback', {
|
|
292
|
+
trigger,
|
|
293
|
+
canaryVersion: this.config.canary.version,
|
|
294
|
+
stableVersion: this.config.stable.version,
|
|
295
|
+
metrics: this.getMetrics(),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
findStepIndex(weight) {
|
|
299
|
+
const steps = this.config.promotion.steps;
|
|
300
|
+
for (let i = 0; i < steps.length; i++) {
|
|
301
|
+
if (steps[i] >= weight)
|
|
302
|
+
return i;
|
|
303
|
+
}
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
exports.CanaryEngine = CanaryEngine;
|
|
308
|
+
// ─── Utilities ───
|
|
309
|
+
/**
|
|
310
|
+
* Parse an interval string like '5m', '10s', '1h' to milliseconds.
|
|
311
|
+
* Also accepts raw numbers (treated as ms).
|
|
312
|
+
*/
|
|
313
|
+
function parseInterval(value) {
|
|
314
|
+
if (typeof value === 'number')
|
|
315
|
+
return value;
|
|
316
|
+
const match = value.match(/^(\d+)(ms|s|m|h)$/);
|
|
317
|
+
if (!match) {
|
|
318
|
+
throw new Error(`Invalid interval format: "${value}". Use formats like "5m", "30s", "1h", or a number in ms.`);
|
|
319
|
+
}
|
|
320
|
+
const amount = parseInt(match[1], 10);
|
|
321
|
+
const unit = match[2];
|
|
322
|
+
switch (unit) {
|
|
323
|
+
case 'ms':
|
|
324
|
+
return amount;
|
|
325
|
+
case 's':
|
|
326
|
+
return amount * 1000;
|
|
327
|
+
case 'm':
|
|
328
|
+
return amount * 60 * 1000;
|
|
329
|
+
case 'h':
|
|
330
|
+
return amount * 60 * 60 * 1000;
|
|
331
|
+
default:
|
|
332
|
+
return amount;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway Decorators
|
|
3
|
+
* Declarative API for defining gateway routes and policies.
|
|
4
|
+
*/
|
|
5
|
+
import 'reflect-metadata';
|
|
6
|
+
import { GatewayConfig, RouteConfig, ServiceRouteConfig, VersionRouteConfig, CanaryConfig, TrafficPolicyConfig } from '../types';
|
|
7
|
+
import { CircuitBreakerConfig, RateLimiterConfig } from '@hazeljs/resilience';
|
|
8
|
+
type Constructor = new (...args: unknown[]) => unknown;
|
|
9
|
+
/**
|
|
10
|
+
* @Gateway class decorator
|
|
11
|
+
* Marks a class as a gateway definition with global configuration.
|
|
12
|
+
*/
|
|
13
|
+
export declare function Gateway(config: GatewayConfig): ClassDecorator;
|
|
14
|
+
/**
|
|
15
|
+
* @Route property/method decorator
|
|
16
|
+
* Defines a URL pattern this route handles.
|
|
17
|
+
*/
|
|
18
|
+
export declare function Route(pathOrConfig: string | RouteConfig): PropertyDecorator;
|
|
19
|
+
/**
|
|
20
|
+
* @ServiceRoute property/method decorator
|
|
21
|
+
* Maps this route to a service in the discovery registry.
|
|
22
|
+
*/
|
|
23
|
+
export declare function ServiceRoute(nameOrConfig: string | ServiceRouteConfig): PropertyDecorator;
|
|
24
|
+
/**
|
|
25
|
+
* @VersionRoute property/method decorator
|
|
26
|
+
* Enables version-based routing for this route.
|
|
27
|
+
*/
|
|
28
|
+
export declare function VersionRoute(config: VersionRouteConfig): PropertyDecorator;
|
|
29
|
+
/**
|
|
30
|
+
* @Canary property/method decorator
|
|
31
|
+
* Enables canary deployment for this route.
|
|
32
|
+
*/
|
|
33
|
+
export declare function Canary(config: CanaryConfig): PropertyDecorator;
|
|
34
|
+
/**
|
|
35
|
+
* @TrafficPolicy property/method decorator
|
|
36
|
+
* Defines traffic policies (mirroring, transformation, etc.)
|
|
37
|
+
*/
|
|
38
|
+
export declare function TrafficPolicy(config: TrafficPolicyConfig): PropertyDecorator;
|
|
39
|
+
/**
|
|
40
|
+
* @CircuitBreaker property decorator (gateway-specific)
|
|
41
|
+
* Overrides circuit breaker configuration for this route.
|
|
42
|
+
*/
|
|
43
|
+
export declare function GatewayCircuitBreaker(config: Partial<CircuitBreakerConfig>): PropertyDecorator;
|
|
44
|
+
/**
|
|
45
|
+
* @RateLimit property decorator (gateway-specific)
|
|
46
|
+
* Overrides rate limit configuration for this route.
|
|
47
|
+
*/
|
|
48
|
+
export declare function GatewayRateLimit(config: Partial<RateLimiterConfig>): PropertyDecorator;
|
|
49
|
+
export declare function getGatewayConfig(target: Constructor): GatewayConfig | undefined;
|
|
50
|
+
export declare function getRouteConfig(target: object, propertyKey: string | symbol): RouteConfig | undefined;
|
|
51
|
+
export declare function getServiceRouteConfig(target: object, propertyKey: string | symbol): ServiceRouteConfig | undefined;
|
|
52
|
+
export declare function getVersionRouteConfig(target: object, propertyKey: string | symbol): VersionRouteConfig | undefined;
|
|
53
|
+
export declare function getCanaryConfig(target: object, propertyKey: string | symbol): CanaryConfig | undefined;
|
|
54
|
+
export declare function getTrafficPolicyConfig(target: object, propertyKey: string | symbol): TrafficPolicyConfig | undefined;
|
|
55
|
+
export declare function getCircuitBreakerConfig(target: object, propertyKey: string | symbol): Partial<CircuitBreakerConfig> | undefined;
|
|
56
|
+
export declare function getRateLimitConfig(target: object, propertyKey: string | symbol): Partial<RateLimiterConfig> | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Collect all route definitions from a gateway class instance
|
|
59
|
+
*/
|
|
60
|
+
export declare function collectRouteDefinitions(gatewayClass: Constructor): {
|
|
61
|
+
config: GatewayConfig;
|
|
62
|
+
routes: Array<{
|
|
63
|
+
propertyKey: string;
|
|
64
|
+
route?: RouteConfig;
|
|
65
|
+
serviceRoute?: ServiceRouteConfig;
|
|
66
|
+
versionRoute?: VersionRouteConfig;
|
|
67
|
+
canary?: CanaryConfig;
|
|
68
|
+
trafficPolicy?: TrafficPolicyConfig;
|
|
69
|
+
circuitBreaker?: Partial<CircuitBreakerConfig>;
|
|
70
|
+
rateLimit?: Partial<RateLimiterConfig>;
|
|
71
|
+
}>;
|
|
72
|
+
};
|
|
73
|
+
export {};
|
|
74
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/decorators/index.ts"],"names":[],"mappings":"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"}
|