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`; const payload = { appName: this.config.appName, env: this.config.env, version: this.config.version, ignoreGlobal: this.config.ignoreGlobal, token: this.config.token, }; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), 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, token: this.config.token, }; 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';