@alacard-project/config-sdk 1.1.0 → 1.1.2
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/dist/clients/config.client.d.ts +0 -3
- package/dist/clients/config.client.js +11 -70
- package/dist/{modules/config.module.d.ts → config.module.d.ts} +1 -1
- package/dist/{modules/config.module.js → config.module.js} +12 -14
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -2
- package/dist/utils/{nest-helpers.d.ts → config.helpers.d.ts} +1 -1
- package/package.json +25 -21
- package/eslint.config.mjs +0 -29
- package/proto/config.proto +0 -39
- package/src/clients/config.client.ts +0 -252
- package/src/clients/vault.client.ts +0 -82
- package/src/constants/index.ts +0 -1
- package/src/enums/env.enum.ts +0 -7
- package/src/generated/config.ts +0 -834
- package/src/index.ts +0 -8
- package/src/modules/config.module.ts +0 -28
- package/src/types/config.types.ts +0 -38
- package/src/types/grpc.types.ts +0 -15
- package/src/types/types.ts +0 -12
- package/src/utils/nest-helpers.ts +0 -69
- package/test/config.client.spec.ts +0 -108
- package/test/vault.client.spec.ts +0 -62
- package/tsconfig.json +0 -21
- /package/dist/utils/{nest-helpers.js → config.helpers.js} +0 -0
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { ConfigOptions } from '../types/config.types';
|
|
2
2
|
export declare class ConfigClient {
|
|
3
|
-
private readonly logger;
|
|
4
3
|
private configMap;
|
|
5
|
-
private consumer;
|
|
6
4
|
private options;
|
|
7
5
|
private vaultClient;
|
|
8
6
|
private breaker;
|
|
@@ -16,7 +14,6 @@ export declare class ConfigClient {
|
|
|
16
14
|
private loadVaultSecrets;
|
|
17
15
|
private initCircuitBreaker;
|
|
18
16
|
private fetchRemoteConfig;
|
|
19
|
-
private startWatching;
|
|
20
17
|
get<T = string>(key: string, defaultValue?: T): T;
|
|
21
18
|
getInt(key: string, defaultValue?: number): number;
|
|
22
19
|
getBool(key: string, defaultValue?: boolean): boolean;
|
|
@@ -38,17 +38,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.ConfigClient = void 0;
|
|
40
40
|
const grpc = __importStar(require("@grpc/grpc-js"));
|
|
41
|
-
const kafkajs_1 = require("kafkajs");
|
|
42
41
|
const path = __importStar(require("path"));
|
|
43
42
|
const dotenv = __importStar(require("dotenv"));
|
|
44
43
|
const opossum_1 = __importDefault(require("opossum"));
|
|
45
|
-
const common_1 = require("@nestjs/common");
|
|
46
44
|
const config_1 = require("../generated/config");
|
|
47
45
|
const vault_client_1 = require("./vault.client");
|
|
48
46
|
class ConfigClient {
|
|
49
|
-
logger = new common_1.Logger('ConfigSDK');
|
|
50
47
|
configMap = {};
|
|
51
|
-
consumer = null;
|
|
52
48
|
options;
|
|
53
49
|
vaultClient = null;
|
|
54
50
|
breaker = null;
|
|
@@ -86,15 +82,11 @@ class ConfigClient {
|
|
|
86
82
|
}
|
|
87
83
|
// 4. Fetch Remote Config via gRPC (with Circuit Breaker)
|
|
88
84
|
await this.fetchRemoteConfig(serviceName, environment, grpcUrl, version);
|
|
89
|
-
|
|
90
|
-
if (this.options.kafkaBrokers && this.options.kafkaBrokers.length > 0) {
|
|
91
|
-
await this.startWatching();
|
|
92
|
-
}
|
|
93
|
-
this.logger.log(`Configuration initialized with ${Object.keys(this.configMap).length} keys`);
|
|
85
|
+
console.log(`[ConfigSDK] Configuration initialized with ${Object.keys(this.configMap).length} keys`);
|
|
94
86
|
}
|
|
95
87
|
catch (error) {
|
|
96
88
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
97
|
-
|
|
89
|
+
console.error(`[ConfigSDK] Critical initialization failure: ${errorMsg}. Service starting with loaded config.`);
|
|
98
90
|
}
|
|
99
91
|
}
|
|
100
92
|
loadProcessEnv() {
|
|
@@ -106,19 +98,17 @@ class ConfigClient {
|
|
|
106
98
|
}
|
|
107
99
|
}
|
|
108
100
|
loadDotEnv(environment) {
|
|
109
|
-
// Only support standard .env and environment-specific .env
|
|
110
101
|
const envFiles = [`.env.${environment}`, '.env'];
|
|
111
102
|
for (const file of envFiles) {
|
|
112
103
|
const filePath = path.resolve(process.cwd(), file);
|
|
113
104
|
const result = dotenv.config({ path: filePath });
|
|
114
105
|
if (result.parsed) {
|
|
115
|
-
// Do not overwrite existing keys (process.env wins)
|
|
116
106
|
for (const [key, value] of Object.entries(result.parsed)) {
|
|
117
107
|
if (!this.configMap[key]) {
|
|
118
108
|
this.configMap[key] = value;
|
|
119
109
|
}
|
|
120
110
|
}
|
|
121
|
-
|
|
111
|
+
console.log(`[ConfigSDK] Loaded variables from ${file}`);
|
|
122
112
|
}
|
|
123
113
|
}
|
|
124
114
|
}
|
|
@@ -128,19 +118,16 @@ class ConfigClient {
|
|
|
128
118
|
this.vaultClient = new vault_client_1.VaultClient(this.options.vault);
|
|
129
119
|
try {
|
|
130
120
|
const vaultSecrets = await this.vaultClient.getKVSecrets(`config-service/${serviceName}`);
|
|
131
|
-
// Vault secrets should overwrite env vars if needed, or fill gaps.
|
|
132
|
-
// Usually Vault > Env, but here we treat Env > Vault for local overrides.
|
|
133
|
-
// Let's adopt a safe merge: Existing keys (from Env) take precedence.
|
|
134
121
|
for (const [key, value] of Object.entries(vaultSecrets)) {
|
|
135
122
|
if (!this.configMap[key]) {
|
|
136
123
|
this.configMap[key] = value;
|
|
137
124
|
}
|
|
138
125
|
}
|
|
139
|
-
|
|
126
|
+
console.log(`[ConfigSDK] Loaded secrets from Vault for ${serviceName}`);
|
|
140
127
|
}
|
|
141
128
|
catch (error) {
|
|
142
129
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
143
|
-
|
|
130
|
+
console.warn(`[ConfigSDK] Vault secrets unavailable: ${errorMsg}. Continuing with local config.`);
|
|
144
131
|
}
|
|
145
132
|
}
|
|
146
133
|
initCircuitBreaker(client) {
|
|
@@ -154,13 +141,10 @@ class ConfigClient {
|
|
|
154
141
|
});
|
|
155
142
|
};
|
|
156
143
|
this.breaker = new opossum_1.default(fetchFunction, {
|
|
157
|
-
timeout: 5000,
|
|
158
|
-
errorThresholdPercentage: 50,
|
|
159
|
-
resetTimeout: 10000,
|
|
144
|
+
timeout: 5000,
|
|
145
|
+
errorThresholdPercentage: 50,
|
|
146
|
+
resetTimeout: 10000,
|
|
160
147
|
});
|
|
161
|
-
this.breaker.on('open', () => this.logger.warn('Circuit Breaker (Config gRPC) is OPEN'));
|
|
162
|
-
this.breaker.on('halfOpen', () => this.logger.log('Circuit Breaker (Config gRPC) is HALF_OPEN'));
|
|
163
|
-
this.breaker.on('close', () => this.logger.log('Circuit Breaker (Config gRPC) is CLOSED'));
|
|
164
148
|
}
|
|
165
149
|
async fetchRemoteConfig(serviceName, environment, grpcUrl, version) {
|
|
166
150
|
let credentials = grpc.credentials.createInsecure();
|
|
@@ -179,59 +163,17 @@ class ConfigClient {
|
|
|
179
163
|
try {
|
|
180
164
|
const values = await this.breaker.fire(request, metadata);
|
|
181
165
|
if (values) {
|
|
182
|
-
// Remote config fills in the gaps
|
|
183
166
|
for (const [key, value] of Object.entries(values)) {
|
|
184
167
|
if (!this.configMap[key]) {
|
|
185
168
|
this.configMap[key] = value;
|
|
186
169
|
}
|
|
187
170
|
}
|
|
188
|
-
|
|
171
|
+
console.log(`[ConfigSDK] Remote config loaded via gRPC`);
|
|
189
172
|
}
|
|
190
173
|
}
|
|
191
174
|
catch (error) {
|
|
192
175
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
async startWatching() {
|
|
197
|
-
if (this.consumer)
|
|
198
|
-
return;
|
|
199
|
-
try {
|
|
200
|
-
const kafka = new kafkajs_1.Kafka({
|
|
201
|
-
clientId: `config-sdk-${this.options.serviceName}`,
|
|
202
|
-
brokers: this.options.kafkaBrokers,
|
|
203
|
-
retry: {
|
|
204
|
-
retries: 5, // Retry up to 5 times
|
|
205
|
-
maxRetryTime: 30000 // Max time spent retrying
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
const groupId = `config-watch-${this.options.serviceName}-${this.options.environment}`;
|
|
209
|
-
this.consumer = kafka.consumer({ groupId });
|
|
210
|
-
await this.consumer.connect();
|
|
211
|
-
await this.consumer.subscribe({ topic: 'config.updated', fromBeginning: false });
|
|
212
|
-
await this.consumer.run({
|
|
213
|
-
eachMessage: async ({ message }) => {
|
|
214
|
-
if (!message.value)
|
|
215
|
-
return;
|
|
216
|
-
try {
|
|
217
|
-
const event = JSON.parse(message.value.toString());
|
|
218
|
-
if (event.service === 'global' || event.service === this.options.serviceName) {
|
|
219
|
-
if (event.environment === this.options.environment) {
|
|
220
|
-
this.logger.log(`Hot-reload: ${event.key} updated`);
|
|
221
|
-
this.configMap[event.key] = event.value;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
catch (err) {
|
|
226
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
227
|
-
this.logger.error(`Failed to parse config update: ${errorMsg}`);
|
|
228
|
-
}
|
|
229
|
-
},
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
catch (error) {
|
|
233
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
234
|
-
this.logger.warn(`Config watch mode (Kafka) failed: ${errorMsg}`);
|
|
176
|
+
console.warn(`[ConfigSDK] Remote config (gRPC) failure: ${errorMsg}. Using local/vault fallback.`);
|
|
235
177
|
}
|
|
236
178
|
}
|
|
237
179
|
get(key, defaultValue) {
|
|
@@ -240,8 +182,7 @@ class ConfigClient {
|
|
|
240
182
|
if (defaultValue !== undefined) {
|
|
241
183
|
return defaultValue;
|
|
242
184
|
}
|
|
243
|
-
|
|
244
|
-
this.logger.warn(`Configuration key '${key}' not found and no default value provided.`);
|
|
185
|
+
console.warn(`[ConfigSDK] Configuration key '${key}' not found and no default value provided.`);
|
|
245
186
|
return undefined;
|
|
246
187
|
}
|
|
247
188
|
return val;
|
|
@@ -36,8 +36,7 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.ConfigModule = void 0;
|
|
38
38
|
const common_1 = require("@nestjs/common");
|
|
39
|
-
const config_client_1 = require("
|
|
40
|
-
const constants_1 = require("../constants");
|
|
39
|
+
const config_client_1 = require("./clients/config.client");
|
|
41
40
|
let ConfigModule = (() => {
|
|
42
41
|
let _classDecorators = [(0, common_1.Global)(), (0, common_1.Module)({})];
|
|
43
42
|
let _classDescriptor;
|
|
@@ -53,20 +52,19 @@ let ConfigModule = (() => {
|
|
|
53
52
|
__runInitializers(_classThis, _classExtraInitializers);
|
|
54
53
|
}
|
|
55
54
|
static forRoot(options) {
|
|
56
|
-
const optionsProvider = {
|
|
57
|
-
provide: constants_1.CONFIG_OPTIONS,
|
|
58
|
-
useValue: options,
|
|
59
|
-
};
|
|
60
|
-
const configClientProvider = {
|
|
61
|
-
provide: config_client_1.ConfigClient,
|
|
62
|
-
useFactory: async () => {
|
|
63
|
-
return await config_client_1.ConfigClient.initialize(options);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
55
|
return {
|
|
67
56
|
module: ConfigModule,
|
|
68
|
-
providers: [
|
|
69
|
-
|
|
57
|
+
providers: [
|
|
58
|
+
{
|
|
59
|
+
provide: 'CONFIG_OPTIONS',
|
|
60
|
+
useValue: options,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
provide: config_client_1.ConfigClient,
|
|
64
|
+
useFactory: async () => await config_client_1.ConfigClient.initialize(options),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
exports: [config_client_1.ConfigClient],
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
70
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export * from './clients/config.client';
|
|
2
2
|
export * from './clients/vault.client';
|
|
3
|
-
export * from './
|
|
4
|
-
export * from './utils/nest-helpers';
|
|
3
|
+
export * from './utils/config.helpers';
|
|
5
4
|
export * from './constants';
|
|
6
5
|
export * from './enums/env.enum';
|
|
7
6
|
export * from './types/config.types';
|
|
8
7
|
export * from './types/grpc.types';
|
|
8
|
+
export { ConfigServiceClient } from './generated/config';
|
|
9
|
+
export * from './config.module';
|
package/dist/index.js
CHANGED
|
@@ -14,11 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.ConfigServiceClient = void 0;
|
|
17
18
|
__exportStar(require("./clients/config.client"), exports);
|
|
18
19
|
__exportStar(require("./clients/vault.client"), exports);
|
|
19
|
-
__exportStar(require("./
|
|
20
|
-
__exportStar(require("./utils/nest-helpers"), exports);
|
|
20
|
+
__exportStar(require("./utils/config.helpers"), exports);
|
|
21
21
|
__exportStar(require("./constants"), exports);
|
|
22
22
|
__exportStar(require("./enums/env.enum"), exports);
|
|
23
23
|
__exportStar(require("./types/config.types"), exports);
|
|
24
24
|
__exportStar(require("./types/grpc.types"), exports);
|
|
25
|
+
var config_1 = require("./generated/config");
|
|
26
|
+
Object.defineProperty(exports, "ConfigServiceClient", { enumerable: true, get: function () { return config_1.ConfigServiceClient; } });
|
|
27
|
+
__exportStar(require("./config.module"), exports);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Environment } from '../enums/env.enum';
|
|
2
2
|
export declare abstract class BaseConfigService {
|
|
3
3
|
constructor();
|
|
4
|
-
|
|
4
|
+
get<T = string>(key: string, defaultValue?: T): T;
|
|
5
5
|
getRequiredString(key: string): string;
|
|
6
6
|
getRequiredNumber(key: string): number;
|
|
7
7
|
getOptionalString(key: string, defaultValue: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alacard-project/config-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=24.0.0"
|
|
6
6
|
},
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
14
18
|
"scripts": {
|
|
15
19
|
"build": "tsc",
|
|
16
20
|
"lint": "eslint \"src/**/*.ts\" --fix",
|
|
@@ -20,27 +24,27 @@
|
|
|
20
24
|
"prepublishOnly": "npm run build"
|
|
21
25
|
},
|
|
22
26
|
"dependencies": {
|
|
23
|
-
"@bufbuild/protobuf": "
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
27
|
+
"@bufbuild/protobuf": "2.2.3"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@grpc/grpc-js": "^1.11.0",
|
|
31
|
+
"@grpc/proto-loader": "^0.7.0",
|
|
32
|
+
"axios": "^1.7.0",
|
|
33
|
+
"dotenv": "^16.0.0 || ^17.0.0",
|
|
34
|
+
"opossum": "^8.0.0 || ^9.0.0"
|
|
30
35
|
},
|
|
31
36
|
"devDependencies": {
|
|
32
|
-
"@eslint/js": "
|
|
33
|
-
"@
|
|
34
|
-
"@types/
|
|
35
|
-
"@types/
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"jest": "
|
|
40
|
-
"ts-
|
|
41
|
-
"
|
|
42
|
-
"typescript": "
|
|
43
|
-
"typescript-eslint": "^8.54.0"
|
|
37
|
+
"@eslint/js": "9.18.0",
|
|
38
|
+
"@types/jest": "29.5.14",
|
|
39
|
+
"@types/node": "22.10.10",
|
|
40
|
+
"@types/opossum": "8.1.9",
|
|
41
|
+
"eslint": "9.18.0",
|
|
42
|
+
"globals": "17.3.0",
|
|
43
|
+
"jest": "29.7.0",
|
|
44
|
+
"ts-jest": "29.2.6",
|
|
45
|
+
"ts-proto": "2.6.1",
|
|
46
|
+
"typescript": "5.7.3",
|
|
47
|
+
"typescript-eslint": "8.20.0"
|
|
44
48
|
},
|
|
45
49
|
"jest": {
|
|
46
50
|
"moduleFileExtensions": [
|
|
@@ -59,4 +63,4 @@
|
|
|
59
63
|
"coverageDirectory": "./coverage",
|
|
60
64
|
"testEnvironment": "node"
|
|
61
65
|
}
|
|
62
|
-
}
|
|
66
|
+
}
|
package/eslint.config.mjs
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import js from "@eslint/js";
|
|
2
|
-
import globals from "globals";
|
|
3
|
-
import tsParser from "@typescript-eslint/parser";
|
|
4
|
-
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
|
5
|
-
|
|
6
|
-
export default [
|
|
7
|
-
js.configs.recommended,
|
|
8
|
-
{
|
|
9
|
-
ignores: ["src/generated/**/*"],
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
files: ["src/**/*.ts"],
|
|
13
|
-
languageOptions: {
|
|
14
|
-
parser: tsParser,
|
|
15
|
-
globals: {
|
|
16
|
-
...globals.node,
|
|
17
|
-
...globals.jest,
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
plugins: {
|
|
21
|
-
"@typescript-eslint": tsPlugin,
|
|
22
|
-
},
|
|
23
|
-
rules: {
|
|
24
|
-
"no-console": "error",
|
|
25
|
-
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
|
26
|
-
"@typescript-eslint/no-explicit-any": "warn",
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
];
|
package/proto/config.proto
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
syntax = "proto3";
|
|
2
|
-
|
|
3
|
-
package config;
|
|
4
|
-
|
|
5
|
-
service ConfigService {
|
|
6
|
-
rpc GetConfig (GetConfigRequest) returns (ConfigResponse);
|
|
7
|
-
rpc SetConfig (SetConfigRequest) returns (ConfigResponse);
|
|
8
|
-
rpc ListConfigs (ListConfigsRequest) returns (ListConfigsResponse);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
message GetConfigRequest {
|
|
12
|
-
string serviceName = 1;
|
|
13
|
-
string environment = 2;
|
|
14
|
-
string version = 3;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
message ConfigResponse {
|
|
18
|
-
map<string, string> values = 1;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
message SetConfigRequest {
|
|
22
|
-
string serviceName = 1;
|
|
23
|
-
string environment = 2;
|
|
24
|
-
string key = 3;
|
|
25
|
-
string value = 4;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
message ListConfigsRequest {
|
|
29
|
-
string environment = 1;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
message ServiceConfig {
|
|
33
|
-
string serviceName = 1;
|
|
34
|
-
map<string, string> values = 2;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
message ListConfigsResponse {
|
|
38
|
-
repeated ServiceConfig configs = 1;
|
|
39
|
-
}
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
import * as grpc from '@grpc/grpc-js';
|
|
2
|
-
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import * as dotenv from 'dotenv';
|
|
5
|
-
import CircuitBreaker from 'opossum';
|
|
6
|
-
import { Logger } from '@nestjs/common';
|
|
7
|
-
import { ConfigServiceClient } from '../generated/config';
|
|
8
|
-
import { GetConfigRequest } from '../types/grpc.types';
|
|
9
|
-
import { VaultClient } from './vault.client';
|
|
10
|
-
import { ConfigOptions, ConfigUpdatedEvent } from '../types/config.types';
|
|
11
|
-
|
|
12
|
-
export class ConfigClient {
|
|
13
|
-
private readonly logger: Logger = new Logger('ConfigSDK');
|
|
14
|
-
private configMap: Record<string, string> = {};
|
|
15
|
-
private consumer: Consumer | null = null;
|
|
16
|
-
private options: ConfigOptions;
|
|
17
|
-
private vaultClient: VaultClient | null = null;
|
|
18
|
-
private breaker: CircuitBreaker | null = null;
|
|
19
|
-
private static instance: ConfigClient | null = null;
|
|
20
|
-
|
|
21
|
-
private constructor(options: ConfigOptions) {
|
|
22
|
-
this.options = options;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
public static async initialize(options: ConfigOptions): Promise<ConfigClient> {
|
|
26
|
-
if (this.instance) {
|
|
27
|
-
return this.instance;
|
|
28
|
-
}
|
|
29
|
-
const client = new ConfigClient(options);
|
|
30
|
-
await client.init();
|
|
31
|
-
this.instance = client;
|
|
32
|
-
return client;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
public static getInstance(): ConfigClient {
|
|
36
|
-
if (!this.instance) {
|
|
37
|
-
throw new Error('[ConfigSDK] ConfigClient not initialized. Call initialize() first.');
|
|
38
|
-
}
|
|
39
|
-
return this.instance;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private async init(): Promise<void> {
|
|
43
|
-
const { serviceName, environment, grpcUrl, version = 'v1', useDotenvFallback = true } = this.options;
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
// 1. Load from Process Env (Highest Priority for overrides)
|
|
47
|
-
this.loadProcessEnv();
|
|
48
|
-
|
|
49
|
-
// 2. Load from DotEnv (if enabled)
|
|
50
|
-
if (useDotenvFallback) {
|
|
51
|
-
this.loadDotEnv(environment);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 3. Load from Vault (if configured)
|
|
55
|
-
if (this.options.vault) {
|
|
56
|
-
await this.loadVaultSecrets(serviceName);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 4. Fetch Remote Config via gRPC (with Circuit Breaker)
|
|
60
|
-
await this.fetchRemoteConfig(serviceName, environment, grpcUrl, version);
|
|
61
|
-
|
|
62
|
-
// 5. Start Kafka Watcher (if configured)
|
|
63
|
-
if (this.options.kafkaBrokers && this.options.kafkaBrokers.length > 0) {
|
|
64
|
-
await this.startWatching();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
this.logger.log(`Configuration initialized with ${Object.keys(this.configMap).length} keys`);
|
|
68
|
-
} catch (error: unknown) {
|
|
69
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
70
|
-
this.logger.error(`Critical initialization failure: ${errorMsg}. Service starting with loaded config.`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private loadProcessEnv(): void {
|
|
75
|
-
const ALLOWED_ENV_PREFIXES = ['APP_', 'KAFKA_', 'DB_', 'REDIS_', 'GRPC_', 'PORT', 'NODE_ENV', 'DATABASE_URL'];
|
|
76
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
77
|
-
if (value && ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) {
|
|
78
|
-
this.configMap[key] = value;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private loadDotEnv(environment: string): void {
|
|
84
|
-
// Only support standard .env and environment-specific .env
|
|
85
|
-
const envFiles = [`.env.${environment}`, '.env'];
|
|
86
|
-
for (const file of envFiles) {
|
|
87
|
-
const filePath = path.resolve(process.cwd(), file);
|
|
88
|
-
const result = dotenv.config({ path: filePath });
|
|
89
|
-
if (result.parsed) {
|
|
90
|
-
// Do not overwrite existing keys (process.env wins)
|
|
91
|
-
for (const [key, value] of Object.entries(result.parsed)) {
|
|
92
|
-
if (!this.configMap[key]) {
|
|
93
|
-
this.configMap[key] = value;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
this.logger.log(`Loaded variables from ${file}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private async loadVaultSecrets(serviceName: string): Promise<void> {
|
|
102
|
-
if (!this.options.vault) return;
|
|
103
|
-
|
|
104
|
-
this.vaultClient = new VaultClient(this.options.vault);
|
|
105
|
-
try {
|
|
106
|
-
const vaultSecrets = await this.vaultClient.getKVSecrets(`config-service/${serviceName}`);
|
|
107
|
-
// Vault secrets should overwrite env vars if needed, or fill gaps.
|
|
108
|
-
// Usually Vault > Env, but here we treat Env > Vault for local overrides.
|
|
109
|
-
// Let's adopt a safe merge: Existing keys (from Env) take precedence.
|
|
110
|
-
for (const [key, value] of Object.entries(vaultSecrets)) {
|
|
111
|
-
if (!this.configMap[key]) {
|
|
112
|
-
this.configMap[key] = value as string;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
this.logger.log(`Loaded secrets from Vault for ${serviceName}`);
|
|
116
|
-
} catch (error: unknown) {
|
|
117
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
118
|
-
this.logger.warn(`Vault secrets unavailable: ${errorMsg}. Continuing with local config.`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private initCircuitBreaker(client: ConfigServiceClient): void {
|
|
123
|
-
const fetchFunction = (request: GetConfigRequest, metadata: grpc.Metadata) => {
|
|
124
|
-
return new Promise<Record<string, string>>((resolve, reject) => {
|
|
125
|
-
client.getConfig(request, metadata, (error, response) => {
|
|
126
|
-
if (error) return reject(error);
|
|
127
|
-
resolve(response.values || {});
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
this.breaker = new CircuitBreaker(fetchFunction, {
|
|
133
|
-
timeout: 5000, // 5s timeout
|
|
134
|
-
errorThresholdPercentage: 50, // 50% errors opens the breaker
|
|
135
|
-
resetTimeout: 10000, // wait 10s before trying again
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
this.breaker.on('open', () => this.logger.warn('Circuit Breaker (Config gRPC) is OPEN'));
|
|
139
|
-
this.breaker.on('halfOpen', () => this.logger.log('Circuit Breaker (Config gRPC) is HALF_OPEN'));
|
|
140
|
-
this.breaker.on('close', () => this.logger.log('Circuit Breaker (Config gRPC) is CLOSED'));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private async fetchRemoteConfig(serviceName: string, environment: string, grpcUrl: string, version: string): Promise<void> {
|
|
144
|
-
let credentials = grpc.credentials.createInsecure();
|
|
145
|
-
if (this.options.tls) {
|
|
146
|
-
credentials = grpc.credentials.createSsl(
|
|
147
|
-
this.options.tls.rootCert,
|
|
148
|
-
this.options.tls.clientKey,
|
|
149
|
-
this.options.tls.clientCert,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const client = new ConfigServiceClient(grpcUrl, credentials);
|
|
154
|
-
|
|
155
|
-
if (!this.breaker) {
|
|
156
|
-
this.initCircuitBreaker(client);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const request: GetConfigRequest = { serviceName, environment, version };
|
|
160
|
-
const metadata = new grpc.Metadata();
|
|
161
|
-
if (this.options.internalKey) {
|
|
162
|
-
metadata.set('x-internal-key', this.options.internalKey);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
const values = await this.breaker!.fire(request, metadata);
|
|
167
|
-
if (values) {
|
|
168
|
-
// Remote config fills in the gaps
|
|
169
|
-
for (const [key, value] of Object.entries(values)) {
|
|
170
|
-
if (!this.configMap[key]) {
|
|
171
|
-
this.configMap[key] = value as string;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
this.logger.log(`Remote config loaded from config-service`);
|
|
175
|
-
}
|
|
176
|
-
} catch (error: unknown) {
|
|
177
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
178
|
-
this.logger.warn(`Remote config (gRPC) failure: ${errorMsg}. Using local/vault fallback.`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private async startWatching(): Promise<void> {
|
|
183
|
-
if (this.consumer) return;
|
|
184
|
-
try {
|
|
185
|
-
const kafka = new Kafka({
|
|
186
|
-
clientId: `config-sdk-${this.options.serviceName}`,
|
|
187
|
-
brokers: this.options.kafkaBrokers!,
|
|
188
|
-
retry: {
|
|
189
|
-
retries: 5, // Retry up to 5 times
|
|
190
|
-
maxRetryTime: 30000 // Max time spent retrying
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
const groupId = `config-watch-${this.options.serviceName}-${this.options.environment}`;
|
|
194
|
-
this.consumer = kafka.consumer({ groupId });
|
|
195
|
-
await this.consumer.connect();
|
|
196
|
-
await this.consumer.subscribe({ topic: 'config.updated', fromBeginning: false });
|
|
197
|
-
|
|
198
|
-
await this.consumer.run({
|
|
199
|
-
eachMessage: async ({ message }: EachMessagePayload) => {
|
|
200
|
-
if (!message.value) return;
|
|
201
|
-
try {
|
|
202
|
-
const event: ConfigUpdatedEvent = JSON.parse(message.value.toString());
|
|
203
|
-
if (event.service === 'global' || event.service === this.options.serviceName) {
|
|
204
|
-
if (event.environment === this.options.environment) {
|
|
205
|
-
this.logger.log(`Hot-reload: ${event.key} updated`);
|
|
206
|
-
this.configMap[event.key] = event.value;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
} catch (err: unknown) {
|
|
210
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
211
|
-
this.logger.error(`Failed to parse config update: ${errorMsg}`);
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
} catch (error: unknown) {
|
|
216
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
217
|
-
this.logger.warn(`Config watch mode (Kafka) failed: ${errorMsg}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
public get<T = string>(key: string, defaultValue?: T): T {
|
|
222
|
-
const val = this.configMap[key];
|
|
223
|
-
|
|
224
|
-
if (val === undefined || val === null) {
|
|
225
|
-
if (defaultValue !== undefined) {
|
|
226
|
-
return defaultValue;
|
|
227
|
-
}
|
|
228
|
-
// Strict mode: warn if key is missing and no default value
|
|
229
|
-
this.logger.warn(`Configuration key '${key}' not found and no default value provided.`);
|
|
230
|
-
return undefined as unknown as T;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return val as unknown as T;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
public getInt(key: string, defaultValue: number = 0): number {
|
|
237
|
-
const val = this.get<string>(key);
|
|
238
|
-
if (!val) return defaultValue;
|
|
239
|
-
const parsed = parseInt(val, 10);
|
|
240
|
-
return isNaN(parsed) ? defaultValue : parsed;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
public getBool(key: string, defaultValue: boolean = false): boolean {
|
|
244
|
-
const val = this.get<string>(key);
|
|
245
|
-
if (!val) return defaultValue;
|
|
246
|
-
return val.toLowerCase() === 'true' || val === '1';
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
public getAll(): Record<string, string> {
|
|
250
|
-
return { ...this.configMap };
|
|
251
|
-
}
|
|
252
|
-
}
|