import {
Inject,
Injectable,
Optional,
} from '@angular/core';
import { Environment } from '@rxap/environment';
import {
coerceArray,
CoercePrefix,
deepMerge,
SetObjectValue,
SetToObject,
} from '@rxap/utilities';
import { ReplaySubject } from 'rxjs';
import { RXAP_CONFIG } from './tokens';
import { NoInferType } from './types';
import {
dnsLookup,
fetchCidContentAsJson,
} from './utilities';
export type AnySchema = { validateAsync: (...args: any[]) => any };
export enum ConfigLoadingStrategy {
DEFAULT = 'default',
FIFO = 'fifo',
}
export enum ConfigLoadMethod {
FROM_URLS = 'fromUrls',
FROM_LOCAL_STORAGE = 'fromLocalStorage',
FROM_URL_PARAM = 'fromUrlParam',
FROM_CID = 'fromCid',
}
export interface ConfigLoadOptions {
fromUrlParam?: string | boolean;
fromUrls?: boolean;
fromLocalStorage?: boolean;
schema?: AnySchema;
url?: string | string[] | ((environment: Environment) => string | string[]);
/**
* static config values
*/
static?: Record<string, any>;
/**
* Load additional configuration based on a CID found in a DNS TXT record.
* If true, uses `location.hostname`. If a string, uses that domain.
*/
fromDns?: string | boolean;
/**
* Load additional configuration directly from the given Content Identifier (CID).
* This takes precedence over `fromDns` if both are provided.
*/
fromCid?: string;
fetchCidContent?: (cid: string, path?: string) => Promise<Blob | null>;
ipfsGatewayServers?: Array<(cid: string) => string>;
dnsServers?: string[];
strategy?: ConfigLoadingStrategy;
/**
* Defines the order and subset of config sources to load.
* If not specified, defaults to: [FROM_URLS, FROM_LOCAL_STORAGE, FROM_URL_PARAM, FROM_CID]
*
* Note: static config is always merged first, and Overwrites are always merged last.
* Note: fromDns is not a source itself - it resolves the CID for FROM_CID.
*/
order?: ConfigLoadMethod[];
}
@Injectable({
providedIn: 'root',
})
export class ConfigService<Config extends Record<string, any> = Record<string, any>> {
public static onError = new ReplaySubject<unknown>(1);
public static onRequestError = new ReplaySubject<Response>(1);
public static onErrorFnc: Array<(error: any) => void> = [];
public static onRequestErrorFnc: Array<(response: Response) => void> = [];
public static Config: any = null;
/**
* Static default values for the config object.
* Will be overwritten by an dynamic config file specified in
* the Urls array.
*/
public static Defaults: any = {};
/**
* Any value definition in the Overwrites object will overwrite any
* value form the Defaults values or dynamic config files
*/
public static Overwrites: any = {};
public static LocalStorageKey = 'rxap/config/local-config';
/**
* @deprecated instead use the url property of the ConfigLoadOptions
*/
public static Urls = [];
public readonly config!: Config;
constructor(@Optional() @Inject(RXAP_CONFIG) config: any | null = null) {
this.config = ConfigService.Config;
if (config) {
this.config = deepMerge(this.config ?? {}, config);
}
if (!this.config) {
throw new Error('config not available');
}
}
/**
* Used to load the app config from a remote resource.
*
* Promise.all([ ConfigService.Load() ])
* .then(() => platformBrowserDynamic().bootstrapModule(AppModule))
* .catch(err => console.error(err))
*
*/
public static async Load(options?: ConfigLoadOptions, environment?: Environment): Promise<void> {
options ??= {};
let config: any = deepMerge(this.Defaults, {});
if (environment?.config) {
if (typeof environment.config === 'string') {
options.url = environment.config;
} else {
options.static = environment.config.static;
options.url = environment.config.url;
options.fromUrlParam = environment.config.fromUrlParam;
options.fromLocalStorage = environment.config.fromLocalStorage;
options.schema = environment.config.schema;
options.strategy = environment.config.strategy as ConfigLoadingStrategy ?? options.strategy;
}
}
// Always merge static config first
config = deepMerge(config, options?.static ?? {});
// Determine the order of config sources to load
const order = options.order ?? [
ConfigLoadMethod.FROM_URLS,
ConfigLoadMethod.FROM_LOCAL_STORAGE,
ConfigLoadMethod.FROM_URL_PARAM,
ConfigLoadMethod.FROM_CID,
];
// If fromDns is enabled and FROM_CID is in the order, resolve CID first
if (options.fromDns && order.includes(ConfigLoadMethod.FROM_CID) && !options.fromCid) {
await this.loadConfigFromDns(options as any);
}
// Load configs from sources in the specified order
let done = false;
for (const method of order) {
if (done) {
break;
}
// Check if the source is enabled
if (!this.isSourceEnabled(options, method)) {
continue;
}
try {
const loadedConfig = await this.loadConfigFromMethod(method, options, environment);
if (loadedConfig) {
config = deepMerge(config, loadedConfig);
}
// If using FIFO strategy, stop after first successful load
if (options.strategy === ConfigLoadingStrategy.FIFO) {
done = true;
}
} catch (error: any) {
const methodName = this.getMethodName(method);
if (options.strategy !== ConfigLoadingStrategy.FIFO) {
console.error(`Could not load config from ${methodName}`, error);
throw error;
} else {
console.warn(`Could not load config from ${methodName}: ${error.message}. Will try next strategy.`);
}
}
}
if (!done && order.length > 0) {
console.warn('No config loading strategy succeeded. Using default config.');
}
// Always merge overwrites last
config = deepMerge(config, this.Overwrites);
console.debug('app config', config);
this.Config = config;
}
private static isSourceEnabled(options: ConfigLoadOptions, method: ConfigLoadMethod): boolean {
switch (method) {
case ConfigLoadMethod.FROM_URLS:
return options.fromUrls !== false;
case ConfigLoadMethod.FROM_LOCAL_STORAGE:
return options.fromLocalStorage !== false;
case ConfigLoadMethod.FROM_URL_PARAM:
return !!options.fromUrlParam;
case ConfigLoadMethod.FROM_CID:
return !!options.fromCid;
default:
return false;
}
}
private static async loadConfigFromMethod(
method: ConfigLoadMethod,
options: ConfigLoadOptions,
environment?: Environment,
): Promise<any> {
switch (method) {
case ConfigLoadMethod.FROM_URLS:
return await this.loadConfigFromUrls(options, environment);
case ConfigLoadMethod.FROM_LOCAL_STORAGE:
return this.loadConfigFromLocalStorage(options);
case ConfigLoadMethod.FROM_URL_PARAM:
return this.loadConfigFromUrlParam(options as any);
case ConfigLoadMethod.FROM_CID:
return await this.loadConfigFromCid(options as any);
default:
return null;
}
}
private static getMethodName(method: ConfigLoadMethod): string {
switch (method) {
case ConfigLoadMethod.FROM_URLS:
return 'urls';
case ConfigLoadMethod.FROM_LOCAL_STORAGE:
return 'local storage';
case ConfigLoadMethod.FROM_URL_PARAM:
return 'url param';
case ConfigLoadMethod.FROM_CID:
return 'cid';
default:
return 'unknown';
}
}
private static loadConfigFromUrlParam(options: ConfigLoadOptions & { fromUrlParam: string | true }) {
const param = typeof options.fromUrlParam === 'string' ? options.fromUrlParam : 'config';
return this.LoadConfigDefaultFromUrlParam(param);
}
private static loadConfigFromLocalStorage(options: ConfigLoadOptions) {
const localConfig = localStorage.getItem(ConfigService.LocalStorageKey);
if (localConfig) {
return JSON.parse(localConfig);
} else {
return {};
}
}
private static async loadConfigFromUrls(options: ConfigLoadOptions, environment?: Environment) {
let config: any = {};
const urls = (
options?.url ? coerceArray(options.url) : ConfigService.Urls
).map(url => {
if (typeof url === 'function') {
if (!environment) {
throw new Error('environment is required when url is a function');
}
return coerceArray(url(environment));
}
return coerceArray(url);
}).flat();
for (const url of urls) {
const loadedConfig = await this.loadConfig(url, true, options?.schema);
const match = url.match(/config\.([a-zA-Z0-9.\-_]+)\.json/);
if (match) {
SetToObject(config, match[1], loadedConfig);
} else {
config = deepMerge(config, loadedConfig);
}
}
return config;
}
private static async loadConfigFromCid(options: ConfigLoadOptions & { fromCid: string | boolean }) {
console.debug('Loading config from CID: ', options.fromCid);
const ipfsGatewayFunctions = options.ipfsGatewayServers ?? [];
if (!ipfsGatewayFunctions.length) {
console.warn('No IPFS gateway servers provided for fetching content from CID');
return;
}
try {
const cidContent = await fetchCidContentAsJson(
options.fromCid,
undefined,
options.fetchCidContent,
ipfsGatewayFunctions
);
if (cidContent && typeof cidContent === 'object') {
console.log(`Merging configuration from CID ${options.fromCid}.`, cidContent);
// Merge CID content into the existing config object
console.log('Configuration merged successfully.');
return cidContent;
} else if (cidContent) {
console.warn(`Content fetched from CID ${options.fromCid} is not a mergeable object, skipping merge.`);
} else {
console.warn(`No content fetched or content was null for CID ${options.fromCid}.`);
}
} catch (error: any) {
console.error(`Failed to fetch or process content for CID ${options.fromCid}: ${error.message}`);
// Decide how to handle fetch/processing errors
}
}
private static async loadConfigFromDns(options: ConfigLoadOptions & { fromDns: string | boolean }) {
console.debug('Loading config from DNS');
const dnsServers = options.dnsServers ?? [];
if (!dnsServers.length) {
console.warn('No DNS servers provided for DNS lookup');
return;
}
let domain = location.hostname;
if (typeof options.fromDns === 'string') {
domain = options.fromDns;
}
domain = CoercePrefix(domain, '_config.');
try {
console.log(`Attempting DNS lookup for domain: ${domain}`);
const txtData = await dnsLookup(domain, 'TXT', dnsServers);
console.log(`DNS TXT record data found: ${txtData}`);
// Example CID extraction logic (adapt to your TXT record format)
// This looks for IPFS CIDs (v0 'Qm...' or v1 'b...') potentially after 'ipfs://' or '/'
const cidMatch = txtData.match(/(?:ipfs:\/\/|\/|^)([a-zA-Z0-9]{40,})$/);
if (cidMatch && cidMatch[1]) {
options.fromCid = cidMatch[1];
console.log(`Extracted CID from DNS: ${options.fromCid}`);
} else {
console.warn(`Could not extract a valid CID format from DNS TXT data: "${txtData}"`);
}
} catch (error: any) {
console.error(`DNS lookup for ${domain} failed: ${error.message}`);
// Decide if failure is critical or recoverable
}
}
private static handleError(error: any) {
this.onError.next(error);
for (const fnc of this.onErrorFnc) {
try {
fnc(error);
} catch (e: any) {
console.error('Error in onErrorFnc', e);
}
}
}
private static handleRequestError(response: Response) {
this.onRequestError.next(response);
for (const fnc of this.onRequestErrorFnc) {
try {
fnc(response);
} catch (e: any) {
console.error('Error in onRequestErrorFnc', e);
}
}
}
private static async loadConfig<T = any>(url: string, required?: boolean, schema?: AnySchema): Promise<T | null> {
let config: any;
let response: any;
try {
response = await fetch(url);
} catch (error: any) {
const message = `Could not fetch config from '${ url }': ${ error.message }`;
if (required) {
this.handleError(error);
this.showError(message);
throw new Error(message);
} else {
console.warn(message);
return null;
}
}
if (!response.ok) {
let message = `Config request results in non ok response for '${ url }': (${ response.status }) ${ response.statusText }`;
switch (response.status) {
case 404:
message = `Config not found at '${ url }'`;
break;
case 405:
message = `Config service is not started yet. Wait 30s and try again.`;
break;
case 401:
message = `Unauthorized to fetch config from '${ url }'`;
break;
}
if (required) {
this.handleRequestError(response);
this.showError(message);
throw new Error(message);
} else {
console.warn(message);
return null;
}
}
try {
config = await response.json();
} catch (error: any) {
const message = `Could not parse config from '${ url }' to a json object: ${ error.message }`;
if (required) {
this.handleError(error);
this.showError(message);
throw new Error(message);
} else {
console.warn(message);
return null;
}
}
if (schema) {
try {
config = await schema.validateAsync(config);
} catch (error: any) {
const message = `Config from '${ url }' is not valid: ${ error.message }`;
if (required) {
this.handleError(error);
this.showError(message);
throw new Error(message);
} else {
console.warn(message);
return null;
}
}
}
return config;
}
private static LoadConfigDefaultFromUrlParam(param = 'config') {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const configFromParams = {};
for (const configParam of urlParams.getAll('config')) {
try {
const split = configParam.split(';');
if (split.length === 2) {
const keyPath = split[0];
const value = split[1];
SetObjectValue(configFromParams, keyPath, value);
}
} catch (e: any) {
console.warn(`Parsing of url config param failed for '${ configParam }': ${ e.message }`);
}
}
return configFromParams;
}
public static async SideLoad(
url: string,
propertyPath: string,
required?: boolean,
schema?: AnySchema,
): Promise<void> {
if (!this.Config) {
throw new Error('Config side load is only possible after the initial config load.');
}
const config = await this.loadConfig(url, required, schema);
SetObjectValue(this.Config, propertyPath, config);
console.debug(`Side loaded config for '${ propertyPath }' successful`, this.Config);
}
public static Get<T = any, K extends Record<string, any> = Record<string, any>>(
path: string,
defaultValue: T | undefined,
config: Record<string, any>,
): T
public static Get<T = any, K extends Record<string, any> = Record<string, any>>(
path: string,
defaultValue: NoInferType<T>,
config: Record<string, any> = this.Config,
): T {
if (!config) {
throw new Error('config not loaded');
}
let configValue: any = config;
if (typeof path !== 'string') {
throw new Error('The config property path is not a string');
}
for (const fragment of path.split('.')) {
if (configValue && typeof configValue === 'object' && fragment in configValue) {
configValue = configValue[fragment];
} else {
if (defaultValue !== undefined) {
return defaultValue;
}
console.debug(`Config with path '${ path }' not found`);
return undefined as any;
}
}
return configValue;
}
private static showError(message: string) {
const hasUl = document.getElementById('rxap-config-error') !== null;
const ul = document.getElementById('rxap-config-error') ?? document.createElement('ul');
ul.id = 'rxap-config-error';
ul.style.position = 'fixed';
ul.style.bottom = '16px';
ul.style.right = '16px';
ul.style.backgroundColor = 'white';
ul.style.padding = '32px';
ul.style.zIndex = '99999999';
ul.style.color = 'black';
const messageLi = document.createElement('li');
messageLi.innerText = message;
ul.appendChild(messageLi);
const refreshHintLi = document.createElement('li');
refreshHintLi.innerText = 'Please refresh the page to try again.';
ul.appendChild(refreshHintLi);
const autoRefreshHintLi = document.createElement('li');
autoRefreshHintLi.innerText = 'The page will refresh automatically in 30 seconds.';
ul.appendChild(autoRefreshHintLi);
if (!hasUl) {
document.body.appendChild(ul);
}
setTimeout(() => location.reload(), 30000);
}
public setLocalConfig(config: Config): void {
localStorage.setItem(ConfigService.LocalStorageKey, JSON.stringify(config));
}
public clearLocalConfig(): void {
localStorage.removeItem(ConfigService.LocalStorageKey);
}
public get<T = any>(propertyPath: string): T | undefined;
public get<T = any>(propertyPath: string, defaultValue: NoInferType<T>): T;
public get<T = any>(propertyPath: string, defaultValue?: T): T | undefined {
return ConfigService.Get(propertyPath, defaultValue, this.config);
}
public getOrThrow<T = any>(propertyPath: string): T;
public getOrThrow<T = any>(propertyPath: string, defaultValue: NoInferType<T>): T;
public getOrThrow<T = any>(propertyPath: string, defaultValue?: T): T {
const value = ConfigService.Get(propertyPath, defaultValue, this.config);
if (value === undefined) {
throw new Error(`Could not find config in path '${ propertyPath }'`);
}
return value;
}
}