@rxap/config
Version:
Provides a configuration service for Angular applications, allowing you to load and manage application settings from various sources such as static files, URLs, local storage, and URL parameters. It supports schema validation and provides utilities for ac
689 lines (679 loc) • 28.2 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, Inject, InjectionToken, Optional } from '@angular/core';
import { firstValueFrom, Observable, catchError, EMPTY, race, ReplaySubject } from 'rxjs';
import * as i1 from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { finalize, share } from 'rxjs/operators';
import { SetToObject, getFromObject, JoinPath, deepMerge, coerceArray, CoercePrefix, SetObjectValue } from '@rxap/utilities';
import { log } from '@rxap/rxjs';
class ConfigLoaderService {
constructor(http) {
this.http = http;
this.configs = new Map();
this.configLoading = new Map();
}
async load$(url) {
if (this.configs.has(url)) {
return this.configs.get(url);
}
if (this.configLoading.has(url)) {
return this.configLoading.get(url).toPromise();
}
const loading$ = this.http.get(url).pipe(finalize(() => this.configLoading.delete(url)), share());
this.configLoading.set(url, loading$);
return firstValueFrom(loading$);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigLoaderService, deps: [{ token: HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigLoaderService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigLoaderService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: i1.HttpClient, decorators: [{
type: Inject,
args: [HttpClient]
}] }] });
class ConfigTestingService {
constructor() {
this.config = {};
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
clearLocalConfig() {
}
set(propertyPath, value) {
SetToObject(this.config, propertyPath, value);
}
get(propertyPath, defaultValue) {
return getFromObject(this.config, propertyPath, defaultValue);
}
getOrThrow(propertyPath) {
const value = this.get(propertyPath);
if (value === undefined) {
throw new Error(`Could not find config in path '${propertyPath}'`);
}
return value;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
setLocalConfig(config) {
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigTestingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigTestingService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigTestingService, decorators: [{
type: Injectable
}] });
const RXAP_CONFIG = new InjectionToken('rxap/config');
// TODO : move to separate package
/**
* Similar to Promise.race() but only resolves with the first successful promise.
* It will only reject if all promises reject.
*/
async function raceSuccess(promises) {
return new Promise((resolve, reject) => {
let rejectionCount = 0;
promises.forEach((promise) => {
promise.then(
// On success, resolve the entire raceSuccess promise
(value) => resolve(value),
// On rejection, count it and check if all promises rejected
() => {
rejectionCount++;
if (rejectionCount === promises.length) {
reject(new Error('All promises were rejected'));
}
// Otherwise continue waiting for other promises
});
});
});
}
async function dnsResolver(endpoint, name, type) {
const response = await fetch(`${endpoint}?name=${name}&type=${type}`, {
method: 'GET',
headers: {
'Accept': 'application/dns-json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
if (!response.ok) {
throw new Error(`Failed to resolve DNS via ${endpoint} (${response.status}): ${response.statusText}`);
}
const result = await response.json();
// Basic validation of the response structure
if (!result || !Array.isArray(result.Answer) || result.Answer.length === 0 || !result.Answer[0].data) {
throw new Error(`Invalid DNS response structure from ${endpoint} for ${name} ${type}`);
}
const data = result.Answer[0].data;
// Remove surrounding quotes often found in TXT records
return data.replace(/^"(.*)"$/, '$1');
}
const defaultDnsServers = [
'https://dns.google/resolve',
'https://cloudflare-dns.com/dns-query'
];
async function dnsLookup(name, type, dnsServers = defaultDnsServers) {
if (!dnsServers.length) {
throw new Error('No DNS servers provided for lookup');
}
type = type.toUpperCase();
console.log(`Performing DNS lookup for ${type} record of ${name} using servers: ${dnsServers.join(', ')}`);
try {
// Use Promise.race to get the first successful response
return await raceSuccess(dnsServers.map(server => dnsResolver(server, name, type)));
}
catch (error) {
console.error(`Failed to resolve DNS TXT record for ${name} using any server: ${error.message}`);
throw new Error(`DNS lookup failed for ${name} (${type})`); // Re-throw a more specific error
}
}
function fetchContentViaHttp(url) {
return new Observable((subscriber) => {
let controller = new AbortController(); // For cancellation
fetch(url)
.then(response => {
if (!response.ok) {
// Don't complete here, let catchError handle it
throw new Error(`HTTP error ${response.status} for ${url}: ${response.statusText}`);
}
return response.blob();
})
.then(blob => {
if (!subscriber.closed) {
subscriber.next(blob);
subscriber.complete();
}
})
.catch(error => {
if (error.name !== 'AbortError' && !subscriber.closed) {
console.warn(`Fetch error for ${url}:`, error);
}
});
// Cleanup function for when the observable is unsubscribed
return () => {
controller?.abort();
controller = null; // Release reference
};
}).pipe(catchError(error => {
// Log the error but return null to signal failure without stopping the race
console.error(`Fetch failed for ${url}: ${error.message}`);
return EMPTY; // Signal failure with null
}));
}
const w3sIpfsGateway = cid => `https://${cid}.ipfs.w3s.link`;
const storachaIpfsGateway = cid => `https://${cid}.ipfs.storacha.link`;
const localPathIpfsGateway = cid => `${location.origin}/ipfs/${cid}`;
const localSubDomainIpfsGateway = cid => `https://${cid}.ipfs.${location.hostname.split('.').slice(-2).join('.')}`;
const defaultIpfsGatewayServers = [
w3sIpfsGateway,
storachaIpfsGateway,
localPathIpfsGateway,
localSubDomainIpfsGateway
];
function fetchCidContentViaHttp(cid, path, ipfsGatewayServers = defaultIpfsGatewayServers) {
if (!ipfsGatewayServers.length) {
throw new Error('No IPFS gateway servers provided for fetching content');
}
return race(...ipfsGatewayServers.map(fnc => fetchContentViaHttp(JoinPath(fnc(cid), path)).pipe(log(`fetch attempt for ${cid} via ${fnc.name} - ${fnc(cid)}`)))
// Add more sources here if needed
).pipe(log(`Race winner for ${cid}`));
}
async function fetchCidContent(cid, path, ipfsGatewayServers = defaultIpfsGatewayServers) {
console.log(`Fetching content for CID: ${cid}`);
// Example: Fetch from an IPFS gateway or other source
// const gatewayUrl = `https://ipfs.io/ipfs/${cid}`;
try {
return await firstValueFrom(race(fetchCidContentViaHttp(cid, path, ipfsGatewayServers)), { defaultValue: null } // Return null if the stream completes empty
);
}
catch (error) {
console.error(`Error fetching or parsing content for CID ${cid}:`, error);
return null; // Return null on error
}
}
async function fetchCidContentAsJson(cid, path, _fetchCidContent = fetchCidContent, ipfsGatewayServers = defaultIpfsGatewayServers) {
console.log(`Fetching JSON content for CID: ${cid}`);
const blob = await _fetchCidContent(cid, path, ipfsGatewayServers);
if (blob) {
try {
const text = await blob.text();
return JSON.parse(text);
}
catch (error) {
console.error(`Error parsing JSON content for CID ${cid}:`, error);
return null; // Return null on error
}
}
console.warn(`No blob available for CID ${cid}, cannot parse as JSON.`);
return null; // Return null if blob is not available
}
var ConfigLoadingStrategy;
(function (ConfigLoadingStrategy) {
ConfigLoadingStrategy["DEFAULT"] = "default";
ConfigLoadingStrategy["FIFO"] = "fifo";
})(ConfigLoadingStrategy || (ConfigLoadingStrategy = {}));
var ConfigLoadMethod;
(function (ConfigLoadMethod) {
ConfigLoadMethod["FROM_URLS"] = "fromUrls";
ConfigLoadMethod["FROM_LOCAL_STORAGE"] = "fromLocalStorage";
ConfigLoadMethod["FROM_URL_PARAM"] = "fromUrlParam";
ConfigLoadMethod["FROM_CID"] = "fromCid";
})(ConfigLoadMethod || (ConfigLoadMethod = {}));
class ConfigService {
static { this.onError = new ReplaySubject(1); }
static { this.onRequestError = new ReplaySubject(1); }
static { this.onErrorFnc = []; }
static { this.onRequestErrorFnc = []; }
static { this.Config = null; }
/**
* Static default values for the config object.
* Will be overwritten by an dynamic config file specified in
* the Urls array.
*/
static { this.Defaults = {}; }
/**
* Any value definition in the Overwrites object will overwrite any
* value form the Defaults values or dynamic config files
*/
static { this.Overwrites = {}; }
static { this.LocalStorageKey = 'rxap/config/local-config'; }
/**
* @deprecated instead use the url property of the ConfigLoadOptions
*/
static { this.Urls = []; }
constructor(config = 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))
*
*/
static async Load(options, environment) {
options ??= {};
let config = 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 ?? 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);
}
// 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) {
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;
}
static isSourceEnabled(options, method) {
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;
}
}
static async loadConfigFromMethod(method, options, environment) {
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);
case ConfigLoadMethod.FROM_CID:
return await this.loadConfigFromCid(options);
default:
return null;
}
}
static getMethodName(method) {
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';
}
}
static loadConfigFromUrlParam(options) {
const param = typeof options.fromUrlParam === 'string' ? options.fromUrlParam : 'config';
return this.LoadConfigDefaultFromUrlParam(param);
}
static loadConfigFromLocalStorage(options) {
const localConfig = localStorage.getItem(ConfigService.LocalStorageKey);
if (localConfig) {
return JSON.parse(localConfig);
}
else {
return {};
}
}
static async loadConfigFromUrls(options, environment) {
let config = {};
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;
}
static async loadConfigFromCid(options) {
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) {
console.error(`Failed to fetch or process content for CID ${options.fromCid}: ${error.message}`);
// Decide how to handle fetch/processing errors
}
}
static async loadConfigFromDns(options) {
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) {
console.error(`DNS lookup for ${domain} failed: ${error.message}`);
// Decide if failure is critical or recoverable
}
}
static handleError(error) {
this.onError.next(error);
for (const fnc of this.onErrorFnc) {
try {
fnc(error);
}
catch (e) {
console.error('Error in onErrorFnc', e);
}
}
}
static handleRequestError(response) {
this.onRequestError.next(response);
for (const fnc of this.onRequestErrorFnc) {
try {
fnc(response);
}
catch (e) {
console.error('Error in onRequestErrorFnc', e);
}
}
}
static async loadConfig(url, required, schema) {
let config;
let response;
try {
response = await fetch(url);
}
catch (error) {
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) {
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) {
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;
}
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) {
console.warn(`Parsing of url config param failed for '${configParam}': ${e.message}`);
}
}
return configFromParams;
}
static async SideLoad(url, propertyPath, required, schema) {
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);
}
static Get(path, defaultValue, config = this.Config) {
if (!config) {
throw new Error('config not loaded');
}
let configValue = 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;
}
}
return configValue;
}
static showError(message) {
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);
}
setLocalConfig(config) {
localStorage.setItem(ConfigService.LocalStorageKey, JSON.stringify(config));
}
clearLocalConfig() {
localStorage.removeItem(ConfigService.LocalStorageKey);
}
get(propertyPath, defaultValue) {
return ConfigService.Get(propertyPath, defaultValue, this.config);
}
getOrThrow(propertyPath, defaultValue) {
const value = ConfigService.Get(propertyPath, defaultValue, this.config);
if (value === undefined) {
throw new Error(`Could not find config in path '${propertyPath}'`);
}
return value;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigService, deps: [{ token: RXAP_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ConfigService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [RXAP_CONFIG]
}] }] });
function ProvideConfig(config = {}) {
return {
provide: RXAP_CONFIG,
useValue: config,
};
}
// region
// endregion
/**
* Generated bundle index. Do not edit.
*/
export { ConfigLoadMethod, ConfigLoaderService, ConfigLoadingStrategy, ConfigService, ConfigTestingService, ProvideConfig, RXAP_CONFIG, defaultDnsServers, defaultIpfsGatewayServers, dnsLookup, dnsResolver, fetchCidContent, fetchCidContentAsJson, fetchCidContentViaHttp, fetchContentViaHttp, localPathIpfsGateway, localSubDomainIpfsGateway, raceSuccess, storachaIpfsGateway, w3sIpfsGateway };
//# sourceMappingURL=rxap-config.mjs.map