@sixbell-telco/sdk
Version:
A collection of reusable components designed for use in Sixbell Telco Angular projects
340 lines (333 loc) • 11.3 kB
JavaScript
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