UNPKG

@sixbell-telco/sdk

Version:

A collection of reusable components designed for use in Sixbell Telco Angular projects

340 lines (333 loc) 11.3 kB
import Dexie from 'dexie'; class RuntimeHttpClient { options; constructor(options = {}) { this.options = options; } async fetch(url, init) { const retries = this.options.retries ?? 0; const retryDelayMs = this.options.retryDelayMs ?? 0; const timeoutMs = this.options.timeoutMs ?? 0; let lastError = null; for (let attempt = 0; attempt <= retries; attempt += 1) { try { return await this.attemptFetch(url, init, timeoutMs); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < retries && retryDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); } } } throw lastError ?? new Error('Failed to fetch'); } async attemptFetch(url, init, timeoutMs) { if (!timeoutMs || timeoutMs <= 0) { const response = await fetch(url, init); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response; } const controller = new AbortController(); const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { ...init, signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response; } finally { clearTimeout(timeoutHandle); } } } const DEFAULT_RETRIES = 2; const DEFAULT_RETRY_DELAY_MS = 400; const DEFAULT_TIMEOUT_MS = 8000; class RuntimeConfigLoader { configPath; options; retries; retryDelayMs; timeoutMs; httpClient; parser; cacheKey; constructor(configPath, options, retries = DEFAULT_RETRIES, retryDelayMs = DEFAULT_RETRY_DELAY_MS, timeoutMs = DEFAULT_TIMEOUT_MS) { this.configPath = configPath; this.options = options; this.retries = retries; this.retryDelayMs = retryDelayMs; this.timeoutMs = timeoutMs; this.httpClient = new RuntimeHttpClient({ retries: this.retries, retryDelayMs: this.retryDelayMs, timeoutMs: this.timeoutMs, }); this.parser = options.parser ?? (async (response) => (await response.json())); this.cacheKey = this.buildCacheKey(); } async loadLatest() { const logger = this.options.logger; const cached = await this.loadFromStore(); if (cached) { logger?.debug('Runtime config loaded from cache', { component: 'RuntimeConfigLoader', action: 'loadLatest', resource: this.options.resource, configPath: this.configPath, meta: cached.meta, }); return { ...cached, source: 'cache' }; } try { const response = await this.fetchNoStore(); const data = await this.parser(response); const meta = this.extractMeta(data); const hash = await this.resolveHash(data, meta); const result = { data, meta, source: 'network', hash }; await this.storeResult(result); logger?.debug('Runtime config loaded', { component: 'RuntimeConfigLoader', action: 'loadLatest', resource: this.options.resource, configPath: this.configPath, meta, }); return result; } catch (error) { logger?.warn('Runtime config load failed, falling back', { component: 'RuntimeConfigLoader', action: 'loadLatest', resource: this.options.resource, configPath: this.configPath, error: error instanceof Error ? error.message : String(error), }); const fallback = this.options.fallbackData; try { const meta = this.extractMeta(fallback); const hash = await this.resolveHash(fallback, meta); return { data: fallback, meta, source: 'fallback', hash }; } catch { return { data: fallback, meta: {}, source: 'fallback', hash: '' }; } } } async checkForUpdates(currentHash) { try { const response = await this.fetchNoStore(); const data = await this.parser(response); const meta = this.extractMeta(data); const hash = await this.resolveHash(data, meta); return { updated: hash !== currentHash, meta, hash }; } catch { return { updated: false, meta: {}, hash: currentHash }; } } async refresh() { const response = await this.fetchNoStore(); const data = await this.parser(response); const meta = this.extractMeta(data); const hash = await this.resolveHash(data, meta); const result = { data, meta, source: 'network', hash }; await this.storeResult(result); return result; } async loadFromStore() { if (!this.options.store) { return null; } const cached = await this.options.store.get(this.cacheKey); if (!cached) { return null; } if (this.options.schemaVersion) { const cachedSchema = cached.meta.schemaVersion; if (cachedSchema !== this.options.schemaVersion) { await this.options.store.remove(this.cacheKey); return null; } } return cached; } async storeResult(result) { if (!this.options.store) { return; } await this.options.store.set(this.cacheKey, result); } extractMeta(data) { const meta = data.meta; if (!meta?.hash) { throw new Error('Runtime config missing required meta.hash'); } return meta; } async resolveHash(data, meta) { return meta.hash ?? this.hashValue(data); } async hashValue(data) { const value = JSON.stringify(data); if (typeof crypto?.subtle?.digest === 'function') { const encoded = new TextEncoder().encode(value); const buffer = await crypto.subtle.digest('SHA-256', encoded); return this.bufferToHex(buffer); } let hash = 0; for (let i = 0; i < value.length; i += 1) { hash = (hash * 31 + value.charCodeAt(i)) >>> 0; } return `fallback-${hash.toString(16)}`; } bufferToHex(buffer) { const bytes = new Uint8Array(buffer); let hex = ''; for (const byte of bytes) { hex += byte.toString(16).padStart(2, '0'); } return hex; } buildCacheKey() { const prefix = this.options.appId ? `${this.options.appId}:` : ''; const schema = this.options.schemaVersion ? `:schema:${this.options.schemaVersion}` : ''; return `${prefix}${this.options.resource}:${this.configPath}${schema}`; } async fetchNoStore() { return this.httpClient.fetch(this.configPath, { cache: 'no-store' }); } } const DEFAULT_DB_NAME = 'sixbell-runtime-config'; class RuntimeConfigDexie extends Dexie { configs; constructor(dbName) { super(dbName); this.version(1).stores({ configs: 'key,updatedAt', }); } } class RuntimeConfigStore { cacheName; db; fallbackStore = new Map(); constructor(cacheName, appId, indexedDbName = DEFAULT_DB_NAME) { this.cacheName = cacheName; const resolvedDbName = appId ? `${DEFAULT_DB_NAME}:${appId}` : indexedDbName; if (typeof indexedDB === 'undefined') { this.db = undefined; return; } this.db = new RuntimeConfigDexie(resolvedDbName); } async get(key) { if (!this.db) { return this.fallbackStore.get(key) ?? null; } const entry = await this.db.configs.get(this.buildKey(key)); return entry?.payload ?? null; } async set(key, value) { if (!this.db) { this.fallbackStore.set(key, value); return; } await this.db.configs.put({ key: this.buildKey(key), payload: value, updatedAt: Date.now(), }); } async remove(key) { if (!this.db) { this.fallbackStore.delete(key); return; } await this.db.configs.delete(this.buildKey(key)); } buildKey(key) { return `${this.cacheName}:${key}`; } } class RuntimeUpdateAdapter { handler; eventSource; streamSubscription; constructor(handler) { this.handler = handler; } connectSse(url, options) { this.disconnectSse(); this.eventSource = new EventSource(url, { withCredentials: options.withCredentials }); const eventType = options.eventType ?? 'message'; this.eventSource.addEventListener(eventType, (event) => { const message = event; const parsed = this.parseMessage(message, options); if (parsed) { this.handler(parsed, 'sse'); } }); if (this.eventSource) { this.eventSource.onerror = null; } this.eventSource.addEventListener('error', () => { options.logger?.debug('Runtime update stream disconnected', { component: 'RuntimeUpdateAdapter', action: 'connectSse', url, }); }); } connectStream(stream) { this.disconnectStream(); this.streamSubscription = stream.subscribe((event) => { this.handler(event, 'stream'); }); } disconnect() { this.disconnectSse(); this.disconnectStream(); } disconnectSse() { if (this.eventSource) { this.eventSource.close(); this.eventSource = undefined; } } disconnectStream() { this.streamSubscription?.unsubscribe(); this.streamSubscription = undefined; } parseMessage(event, options) { if (options.transform) { return options.transform(event); } if (!event.data) { return { resource: options.resource }; } try { const parsed = JSON.parse(event.data); return parsed; } catch { options.logger?.warn('Failed to parse runtime update event', { component: 'RuntimeUpdateAdapter', action: 'parseMessage', }); return { resource: options.resource }; } } } /** * Generated bundle index. Do not edit. */ export { RuntimeConfigLoader, RuntimeConfigStore, RuntimeHttpClient, RuntimeUpdateAdapter }; //# sourceMappingURL=sixbell-telco-sdk-utils-runtime-config.mjs.map