File

src/lib/config.service.ts

Index

Properties
Methods

Constructor

constructor(config: any | null)
Parameters :
Name Type Optional
config any | null No

Methods

Public clearLocalConfig
clearLocalConfig()
Returns : void
Public get
get(propertyPath: string)
Type parameters :
  • T
Parameters :
Name Type Optional
propertyPath string No
Returns : T | undefined
Public get
get(propertyPath: string, defaultValue: NoInferType<T>)
Type parameters :
  • T
Parameters :
Name Type Optional
propertyPath string No
defaultValue NoInferType<T> No
Returns : T
Public get
get(propertyPath: string, defaultValue?: T)
Type parameters :
  • T
Parameters :
Name Type Optional
propertyPath string No
defaultValue T Yes
Returns : T | undefined
Static Get
Get(path: string, defaultValue: T | undefined, config: Record)
Type parameters :
  • T
  • K
Parameters :
Name Type Optional
path string No
defaultValue T | undefined No
config Record<string | any> No
Returns : T
Static Get
Get(path: string, defaultValue: NoInferType<T>, config: Record)
Type parameters :
  • T
  • K
Parameters :
Name Type Optional Default value
path string No
defaultValue NoInferType<T> No
config Record<string | any> No this.Config
Returns : T
Public getOrThrow
getOrThrow(propertyPath: string)
Type parameters :
  • T
Parameters :
Name Type Optional
propertyPath string No
Returns : T
Public getOrThrow
getOrThrow(propertyPath: string, defaultValue: NoInferType<T>)
Type parameters :
  • T
Parameters :
Name Type Optional
propertyPath string No
defaultValue NoInferType<T> No
Returns : T
Public getOrThrow
getOrThrow(propertyPath: string, defaultValue?: T)
Type parameters :
  • T
Parameters :
Name Type Optional
propertyPath string No
defaultValue T Yes
Returns : T
Static Async Load
Load(options?: ConfigLoadOptions, environment?: Environment)

Used to load the app config from a remote resource.

Promise.all([ ConfigService.Load() ]) .then(() => platformBrowserDynamic().bootstrapModule(AppModule)) .catch(err => console.error(err))

Parameters :
Name Type Optional
options ConfigLoadOptions Yes
environment Environment Yes
Returns : Promise<void>
Public setLocalConfig
setLocalConfig(config: Config)
Parameters :
Name Type Optional
config Config No
Returns : void
Static Async SideLoad
SideLoad(url: string, propertyPath: string, required?: boolean, schema?: AnySchema)
Parameters :
Name Type Optional
url string No
propertyPath string No
required boolean Yes
schema AnySchema Yes
Returns : Promise<void>

Properties

Public Readonly config
Type : Config
Static Config
Type : any
Default value : null
Static Defaults
Type : any
Default value : {}

Static default values for the config object. Will be overwritten by an dynamic config file specified in the Urls array.

Static LocalStorageKey
Type : string
Default value : 'rxap/config/local-config'
Static onError
Default value : new ReplaySubject<unknown>(1)
Static onErrorFnc
Type : Array<void>
Default value : []
Static onRequestError
Default value : new ReplaySubject<Response>(1)
Static onRequestErrorFnc
Type : Array<void>
Default value : []
Static Overwrites
Type : any
Default value : {}

Any value definition in the Overwrites object will overwrite any value form the Defaults values or dynamic config files

Static Urls
instead use the url property of the ConfigLoadOptions
Type : []
Default value : []
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;
  }

}

results matching ""

    No results matching ""