feat(client): 添加 Node.js 客户端及示例应用

- 新增 nodejs-client 模块,提供 ConfigClient 配置拉取、定时同步、变更监听
- 使用 Node.js 内置 crypto 模块实现 AES 解密,零外部依赖
- 新增 nodejs-client-demo Express 示例应用,端口 8083
- 更新 README.md,补充 Python 和 Node.js 客户端使用说明
- 修复 fetchAllConfigs API 响应格式判断逻辑
This commit is contained in:
2026-02-08 21:02:57 +08:00
parent c31746d7d1
commit 98c5b62d0d
13 changed files with 1400 additions and 2 deletions
+99
View File
@@ -0,0 +1,99 @@
# Kenaito Config Center Node.js Client
## 安装
### 安装到本地仓库(推荐)
将包注册为全局符号链接,之后任意项目都可以通过 `npm link kenaito-conf` 引用:
```bash
cd nodejs-client
npm link
```
在你的项目中使用:
```bash
npm link kenaito-conf
```
### 直接引用
```bash
# 在你的项目 package.json 中
"dependencies": {
"kenaito-conf": "file:../nodejs-client"
}
```
```bash
npm install
```
## 快速开始
```javascript
import { Config, ConfigClient } from 'kenaito-conf';
const config = new Config({
baseUrl: 'http://localhost:8080',
appName: 'my-app',
env: 'daily',
syncInterval: 30,
});
const client = new ConfigClient(config);
// 监听配置变更
client.onChange((changed) => {
for (const [k, v] of Object.entries(changed)) {
console.log(`config changed: ${k} = ${v}`);
}
});
// 启动客户端(拉取配置 + 开启定时同步)
await client.start();
// 获取配置
const value = client.get('database.host');
const allConfigs = client.getAll();
// 停止
client.stop();
```
## 配置项
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `baseUrl` | 配置中心地址 | 必填 |
| `appName` | 应用名称 | 必填 |
| `env` | 环境(dev/daily/prod) | `dev` |
| `version` | 配置版本 | `latest` |
| `token` | 认证令牌 | 空 |
| `syncInterval` | 同步间隔(秒) | `60` |
| `ignoreGlobal` | 是否忽略全局配置 | `false` |
| `cacheDir` | 本地缓存目录 | `.` |
## Demo
```bash
cd nodejs-client-demo
npm install
node app.js
```
Demo 默认监听 `8083` 端口,访问 `http://localhost:8083/` 查看配置。
环境变量覆盖:
| 环境变量 | 说明 | 默认值 |
|----------|------|--------|
| `SERVER_PORT` | 服务端口 | `8083` |
| `KENAITO_BASE_URL` | 配置中心地址 | `http://localhost:8080` |
| `KENAITO_APP_NAME` | 应用名称 | `kenaito-nodejs-demo` |
| `KENAITO_PROFILE` | 环境 | `daily` |
| `KENAITO_VERSION` | 配置版本 | `latest` |
| `KENAITO_TOKEN` | 认证令牌 | 空 |
| `KENAITO_SYNC_INTERVAL` | 同步间隔(秒) | `30` |
| `KENAITO_CACHE_DIR` | 缓存目录 | `.` |
+25
View File
@@ -0,0 +1,25 @@
import fs from 'node:fs';
import path from 'node:path';
const CACHE_FILE = 'config-cache.json';
export class Cache {
constructor(cacheDir = '.') {
this.cacheDir = cacheDir;
}
write(data) {
const filePath = path.join(this.cacheDir, CACHE_FILE);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
}
read() {
const filePath = path.join(this.cacheDir, CACHE_FILE);
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
}
+160
View File
@@ -0,0 +1,160 @@
import { Config } from './config.js';
import { Cache } from './cache.js';
import { Listener } from './listener.js';
import { decryptConfig } from './crypto.js';
export class ConfigClient {
constructor(config) {
this.config = config;
this.cache = new Cache(config.cacheDir);
this.listener = new Listener();
this.configs = {};
this.secret = '';
this.globalSecret = '';
this.lastTime = 0;
this.started = false;
this.timer = null;
}
onChange(handler) {
this.listener.onChange(handler);
}
get(key) {
return this.configs[key] ?? null;
}
getAll() {
return { ...this.configs };
}
async start() {
if (this.started) {
return;
}
this.started = true;
try {
await this.fetchAll();
} catch (err) {
console.warn('[ConfigClient] fetch all configs error:', err.message, ', trying cache');
const cached = this.cache.read();
if (cached) {
this.applyConfigs(cached);
console.info('[ConfigClient] loaded', Object.keys(this.configs).length, 'configs from cache');
} else {
throw new Error(`fetch configs failed and no cache: ${err.message}`);
}
}
this.timer = setInterval(() => {
this.fetchChanged().catch((err) => {
console.error('[ConfigClient] sync error:', err);
});
}, this.config.syncInterval * 1000);
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async fetchAll() {
const url = `${this.config.baseUrl}/api/fetchAllConfigs?appName=${encodeURIComponent(this.config.appName)}&env=${encodeURIComponent(this.config.env)}&version=${encodeURIComponent(this.config.version)}&ignoreGlobal=${this.config.ignoreGlobal}`;
const resp = await fetch(url, {
headers: this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {},
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.code != null && data.code !== 0) {
throw new Error(`server error: ${data.msg || 'unknown'}`);
}
const secret = data['kenaito.config.encrypt-secret'];
if (secret) {
this.secret = secret;
}
const globalSecret = data['kenaito.config.global-encrypt-secret'];
if (globalSecret) {
this.globalSecret = globalSecret;
}
const ts = data['latest.fetch.timestamp'];
if (ts) {
this.lastTime = parseInt(ts, 10);
}
this.cache.write(data);
this.applyConfigs(data);
console.info('[ConfigClient] fetched', Object.keys(this.configs).length, 'configs');
}
async fetchChanged() {
const url = `${this.config.baseUrl}/api/fetchChangedConfigs`;
const payload = {
appName: this.config.appName,
env: this.config.env,
version: this.config.version,
lastTime: this.lastTime,
ignoreGlobal: this.config.ignoreGlobal,
};
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {}),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const result = await resp.json();
if (result.code !== 0) {
throw new Error(`server error code: ${result.code}`);
}
const changedData = result.data || {};
const ts = changedData['latest.fetch.timestamp'];
if (ts) {
this.lastTime = parseInt(ts, 10);
}
delete changedData['latest.fetch.timestamp'];
const changed = {};
for (const [k, v] of Object.entries(changedData)) {
changed[k] = this.tryDecrypt(v);
}
Object.assign(this.configs, changed);
this.listener.notify(changed);
console.info('[ConfigClient]', Object.keys(changed).length, 'configs changed');
}
applyConfigs(data) {
for (const [k, v] of Object.entries(data)) {
if (k.startsWith('kenaito.') || k === 'latest.fetch.timestamp') {
continue;
}
if (typeof v !== 'string') {
continue;
}
this.configs[k] = this.tryDecrypt(v);
}
}
tryDecrypt(value) {
let decrypted = decryptConfig(this.secret, value);
if (decrypted === value && this.globalSecret) {
decrypted = decryptConfig(this.globalSecret, value);
}
return decrypted;
}
}
export { Config } from './config.js';
+12
View File
@@ -0,0 +1,12 @@
export class Config {
constructor(options = {}) {
this.baseUrl = options.baseUrl || '';
this.appName = options.appName || '';
this.env = options.env || 'dev';
this.version = options.version || 'latest';
this.token = options.token || '';
this.syncInterval = options.syncInterval || 60;
this.ignoreGlobal = options.ignoreGlobal || false;
this.cacheDir = options.cacheDir || '.';
}
}
+24
View File
@@ -0,0 +1,24 @@
import crypto from 'node:crypto';
const CRYPT_PREFIX = '{Cipher}';
export function decryptConfig(secret, data) {
if (!data.startsWith(CRYPT_PREFIX)) {
return data;
}
const encrypted = data.slice(CRYPT_PREFIX.length);
return decryptWithAES(secret, encrypted);
}
export function decryptWithAES(key, data) {
const md5Hash = crypto.createHash('md5').update(key).digest('hex');
const aesKey = md5Hash.slice(16);
const cipherText = Buffer.from(data, 'base64');
const iv = Buffer.alloc(16, 0);
const decipher = crypto.createDecipheriv('aes-128-cbc', aesKey, iv);
decipher.setAutoPadding(false);
let plainText = Buffer.concat([decipher.update(cipherText), decipher.final()]);
const padding = plainText[plainText.length - 1];
plainText = plainText.slice(0, plainText.length - padding);
return plainText.toString('utf8');
}
+1
View File
@@ -0,0 +1 @@
export { ConfigClient, Config } from './client.js';
+19
View File
@@ -0,0 +1,19 @@
export class Listener {
constructor() {
this.handlers = [];
}
onChange(handler) {
this.handlers.push(handler);
}
notify(changed) {
for (const handler of this.handlers) {
try {
handler(changed);
} catch (err) {
console.error('[ConfigClient] change handler error:', err);
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "kenaito-conf",
"version": "1.0.0",
"description": "Kenaito Config Center Node.js Client",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"config",
"configuration",
"config-center"
],
"author": "odboy",
"license": "MIT"
}