@cygnus-wealth/asset-valuator 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/asset-valuator.js +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/providers/coingecko.d.ts +1 -0
- package/dist/providers/coingecko.js +47 -0
- package/dist/providers/coinpaprika.d.ts +8 -0
- package/dist/providers/coinpaprika.js +64 -0
- package/dist/providers/decentralized-aggregator.d.ts +30 -0
- package/dist/providers/decentralized-aggregator.js +174 -0
- package/dist/utils/browser-detect.d.ts +9 -0
- package/dist/utils/browser-detect.js +18 -0
- package/dist/utils/edge-cache.d.ts +24 -0
- package/dist/utils/edge-cache.js +212 -0
- package/dist/utils/rate-limiter.d.ts +16 -0
- package/dist/utils/rate-limiter.js +60 -0
- package/package.json +2 -1
package/dist/asset-valuator.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DecentralizedAggregator } from './providers/decentralized-aggregator.js';
|
|
2
2
|
export class AssetValuator {
|
|
3
3
|
constructor(provider) {
|
|
4
4
|
this.cache = new Map();
|
|
5
5
|
this.cacheTimeout = 60000; // 1 minute
|
|
6
|
-
this.provider = provider || new
|
|
6
|
+
this.provider = provider || new DecentralizedAggregator();
|
|
7
7
|
}
|
|
8
8
|
getCacheKey(base, quote) {
|
|
9
9
|
return `${base.toUpperCase()}_${quote.toUpperCase()}`;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { AssetValuator } from './asset-valuator.js';
|
|
2
2
|
export { CoinGeckoProvider } from './providers/coingecko.js';
|
|
3
|
+
export { CoinPaprikaProvider } from './providers/coinpaprika.js';
|
|
4
|
+
export { DecentralizedAggregator } from './providers/decentralized-aggregator.js';
|
|
5
|
+
export { RateLimiter } from './utils/rate-limiter.js';
|
|
6
|
+
export { EdgeCache } from './utils/edge-cache.js';
|
|
3
7
|
export { DataModelConverter } from './converters/data-model-converter.js';
|
|
4
8
|
export type { PriceData, AssetPrice, PriceProvider, SupportedCurrency, ConversionOptions } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { AssetValuator } from './asset-valuator.js';
|
|
2
2
|
export { CoinGeckoProvider } from './providers/coingecko.js';
|
|
3
|
+
export { CoinPaprikaProvider } from './providers/coinpaprika.js';
|
|
4
|
+
export { DecentralizedAggregator } from './providers/decentralized-aggregator.js';
|
|
5
|
+
export { RateLimiter } from './utils/rate-limiter.js';
|
|
6
|
+
export { EdgeCache } from './utils/edge-cache.js';
|
|
3
7
|
export { DataModelConverter } from './converters/data-model-converter.js';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PriceData, PriceProvider } from '../types.js';
|
|
2
2
|
export declare class CoinGeckoProvider implements PriceProvider {
|
|
3
3
|
private baseUrl;
|
|
4
|
+
private stablecoins;
|
|
4
5
|
private symbolToIdMap;
|
|
5
6
|
private getCoingeckoId;
|
|
6
7
|
fetchPrice(symbol: string, currency?: string): Promise<PriceData>;
|
|
@@ -2,10 +2,12 @@ import axios from 'axios';
|
|
|
2
2
|
export class CoinGeckoProvider {
|
|
3
3
|
constructor() {
|
|
4
4
|
this.baseUrl = 'https://api.coingecko.com/api/v3';
|
|
5
|
+
this.stablecoins = new Set(['USDC', 'USDT', 'DAI']);
|
|
5
6
|
this.symbolToIdMap = new Map([
|
|
6
7
|
['BTC', 'bitcoin'],
|
|
7
8
|
['ETH', 'ethereum'],
|
|
8
9
|
['USDT', 'tether'],
|
|
10
|
+
['USDC', 'usd-coin'],
|
|
9
11
|
['BNB', 'binancecoin'],
|
|
10
12
|
['SOL', 'solana'],
|
|
11
13
|
['XRP', 'ripple'],
|
|
@@ -18,6 +20,15 @@ export class CoinGeckoProvider {
|
|
|
18
20
|
['UNI', 'uniswap'],
|
|
19
21
|
['ATOM', 'cosmos'],
|
|
20
22
|
['LTC', 'litecoin'],
|
|
23
|
+
['DAI', 'dai'],
|
|
24
|
+
['WBTC', 'wrapped-bitcoin'],
|
|
25
|
+
['AAVE', 'aave'],
|
|
26
|
+
['COMP', 'compound-governance-token'],
|
|
27
|
+
['CRV', 'curve-dao-token'],
|
|
28
|
+
['MKR', 'maker'],
|
|
29
|
+
['SNX', 'synthetix-network-token'],
|
|
30
|
+
['SUSHI', 'sushi'],
|
|
31
|
+
['YFI', 'yearn-finance'],
|
|
21
32
|
]);
|
|
22
33
|
}
|
|
23
34
|
getCoingeckoId(symbol) {
|
|
@@ -44,6 +55,14 @@ export class CoinGeckoProvider {
|
|
|
44
55
|
};
|
|
45
56
|
}
|
|
46
57
|
catch (error) {
|
|
58
|
+
// For stablecoins, return 1 USD if we can't fetch the price
|
|
59
|
+
if (this.stablecoins.has(symbol.toUpperCase()) && currency.toLowerCase() === 'usd') {
|
|
60
|
+
return {
|
|
61
|
+
symbol: symbol.toUpperCase(),
|
|
62
|
+
price: 1,
|
|
63
|
+
timestamp: new Date()
|
|
64
|
+
};
|
|
65
|
+
}
|
|
47
66
|
if (axios.isAxiosError(error)) {
|
|
48
67
|
throw new Error(`Failed to fetch price: ${error.message}`);
|
|
49
68
|
}
|
|
@@ -72,10 +91,38 @@ export class CoinGeckoProvider {
|
|
|
72
91
|
timestamp
|
|
73
92
|
});
|
|
74
93
|
}
|
|
94
|
+
else if (this.stablecoins.has(symbol.toUpperCase()) && currency.toLowerCase() === 'usd') {
|
|
95
|
+
// For stablecoins, return 1 USD if price is not found
|
|
96
|
+
results.push({
|
|
97
|
+
symbol: symbol.toUpperCase(),
|
|
98
|
+
price: 1,
|
|
99
|
+
timestamp
|
|
100
|
+
});
|
|
101
|
+
}
|
|
75
102
|
}
|
|
76
103
|
return results;
|
|
77
104
|
}
|
|
78
105
|
catch (error) {
|
|
106
|
+
// If the entire request fails, check if any symbols are stablecoins
|
|
107
|
+
const results = [];
|
|
108
|
+
const timestamp = new Date();
|
|
109
|
+
for (const symbol of symbols) {
|
|
110
|
+
if (this.stablecoins.has(symbol.toUpperCase()) && currency.toLowerCase() === 'usd') {
|
|
111
|
+
results.push({
|
|
112
|
+
symbol: symbol.toUpperCase(),
|
|
113
|
+
price: 1,
|
|
114
|
+
timestamp
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// If we have stablecoin results, return them; otherwise throw the error
|
|
119
|
+
if (results.length > 0) {
|
|
120
|
+
// For non-stablecoins, we still need to throw an error
|
|
121
|
+
if (results.length < symbols.length) {
|
|
122
|
+
console.warn(`Failed to fetch prices for some assets: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
123
|
+
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
79
126
|
if (axios.isAxiosError(error)) {
|
|
80
127
|
throw new Error(`Failed to fetch prices: ${error.message}`);
|
|
81
128
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { PriceData, PriceProvider } from '../types.js';
|
|
2
|
+
export declare class CoinPaprikaProvider implements PriceProvider {
|
|
3
|
+
private baseUrl;
|
|
4
|
+
private symbolToIdMap;
|
|
5
|
+
private getCoinPaprikaId;
|
|
6
|
+
fetchPrice(symbol: string, currency?: string): Promise<PriceData>;
|
|
7
|
+
fetchMultiplePrices(symbols: string[], currency?: string): Promise<PriceData[]>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
export class CoinPaprikaProvider {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.baseUrl = 'https://api.coinpaprika.com/v1';
|
|
5
|
+
this.symbolToIdMap = new Map([
|
|
6
|
+
['BTC', 'btc-bitcoin'],
|
|
7
|
+
['ETH', 'eth-ethereum'],
|
|
8
|
+
['USDT', 'usdt-tether'],
|
|
9
|
+
['USDC', 'usdc-usd-coin'],
|
|
10
|
+
['BNB', 'bnb-binance-coin'],
|
|
11
|
+
['SOL', 'sol-solana'],
|
|
12
|
+
['XRP', 'xrp-xrp'],
|
|
13
|
+
['ADA', 'ada-cardano'],
|
|
14
|
+
['DOGE', 'doge-dogecoin'],
|
|
15
|
+
['AVAX', 'avax-avalanche'],
|
|
16
|
+
['DOT', 'dot-polkadot'],
|
|
17
|
+
['MATIC', 'matic-polygon'],
|
|
18
|
+
['LINK', 'link-chainlink'],
|
|
19
|
+
['UNI', 'uni-uniswap'],
|
|
20
|
+
['ATOM', 'atom-cosmos'],
|
|
21
|
+
['LTC', 'ltc-litecoin'],
|
|
22
|
+
['DAI', 'dai-dai'],
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
getCoinPaprikaId(symbol) {
|
|
26
|
+
return this.symbolToIdMap.get(symbol.toUpperCase()) || `${symbol.toLowerCase()}-${symbol.toLowerCase()}`;
|
|
27
|
+
}
|
|
28
|
+
async fetchPrice(symbol, currency = 'usd') {
|
|
29
|
+
const id = this.getCoinPaprikaId(symbol);
|
|
30
|
+
const url = `${this.baseUrl}/tickers/${id}`;
|
|
31
|
+
try {
|
|
32
|
+
const response = await axios.get(url);
|
|
33
|
+
const data = response.data;
|
|
34
|
+
if (!data || !data.quotes || !data.quotes.USD) {
|
|
35
|
+
throw new Error(`Price not found for ${symbol}`);
|
|
36
|
+
}
|
|
37
|
+
// CoinPaprika only provides USD prices in free tier
|
|
38
|
+
const priceInUSD = data.quotes.USD.price;
|
|
39
|
+
let price = priceInUSD;
|
|
40
|
+
if (currency.toLowerCase() !== 'usd') {
|
|
41
|
+
// For other currencies, we'd need to convert
|
|
42
|
+
// This is a limitation of the free API
|
|
43
|
+
throw new Error(`CoinPaprika free tier only supports USD prices`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
symbol: symbol.toUpperCase(),
|
|
47
|
+
price,
|
|
48
|
+
timestamp: new Date()
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (axios.isAxiosError(error)) {
|
|
53
|
+
throw new Error(`Failed to fetch price from CoinPaprika: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async fetchMultiplePrices(symbols, currency = 'usd') {
|
|
59
|
+
// CoinPaprika requires individual requests for each coin
|
|
60
|
+
const promises = symbols.map(symbol => this.fetchPrice(symbol, currency).catch(() => null));
|
|
61
|
+
const results = await Promise.all(promises);
|
|
62
|
+
return results.filter((result) => result !== null);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PriceData, PriceProvider } from '../types.js';
|
|
2
|
+
export interface AggregatorOptions {
|
|
3
|
+
providers?: PriceProvider[];
|
|
4
|
+
cacheOptions?: {
|
|
5
|
+
storage?: 'memory' | 'localStorage' | 'indexedDB';
|
|
6
|
+
ttl?: number;
|
|
7
|
+
};
|
|
8
|
+
rateLimitOptions?: {
|
|
9
|
+
maxRequests?: number;
|
|
10
|
+
windowMs?: number;
|
|
11
|
+
};
|
|
12
|
+
consensusThreshold?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare class DecentralizedAggregator implements PriceProvider {
|
|
15
|
+
private providers;
|
|
16
|
+
private rateLimiter;
|
|
17
|
+
private cache;
|
|
18
|
+
private consensusThreshold;
|
|
19
|
+
constructor(options?: AggregatorOptions);
|
|
20
|
+
private selectBestStorage;
|
|
21
|
+
fetchPrice(symbol: string, currency?: string): Promise<PriceData>;
|
|
22
|
+
fetchMultiplePrices(symbols: string[], currency?: string): Promise<PriceData[]>;
|
|
23
|
+
private fetchFromProviders;
|
|
24
|
+
private calculateConsensusPrice;
|
|
25
|
+
private groupBySymbol;
|
|
26
|
+
addProvider(provider: PriceProvider): void;
|
|
27
|
+
removeProvider(index: number): void;
|
|
28
|
+
clearCache(): Promise<void>;
|
|
29
|
+
resetRateLimiter(): void;
|
|
30
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { CoinGeckoProvider } from './coingecko.js';
|
|
2
|
+
import { CoinPaprikaProvider } from './coinpaprika.js';
|
|
3
|
+
import { RateLimiter } from '../utils/rate-limiter.js';
|
|
4
|
+
import { EdgeCache } from '../utils/edge-cache.js';
|
|
5
|
+
import { hasIndexedDB, hasLocalStorage } from '../utils/browser-detect.js';
|
|
6
|
+
export class DecentralizedAggregator {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
// Default providers
|
|
9
|
+
this.providers = options.providers || [
|
|
10
|
+
new CoinGeckoProvider(),
|
|
11
|
+
new CoinPaprikaProvider(),
|
|
12
|
+
];
|
|
13
|
+
// Rate limiter with conservative defaults for edge devices
|
|
14
|
+
this.rateLimiter = new RateLimiter({
|
|
15
|
+
maxRequests: options.rateLimitOptions?.maxRequests || 5,
|
|
16
|
+
windowMs: options.rateLimitOptions?.windowMs || 60000, // 1 minute
|
|
17
|
+
retryAfterMs: 2000,
|
|
18
|
+
maxRetries: 3
|
|
19
|
+
});
|
|
20
|
+
// Edge cache with automatic storage selection
|
|
21
|
+
this.cache = new EdgeCache({
|
|
22
|
+
storage: options.cacheOptions?.storage || this.selectBestStorage(),
|
|
23
|
+
defaultTTL: options.cacheOptions?.ttl || 60000, // 60 seconds
|
|
24
|
+
maxSize: 500
|
|
25
|
+
});
|
|
26
|
+
this.consensusThreshold = options.consensusThreshold || 0.5;
|
|
27
|
+
}
|
|
28
|
+
selectBestStorage() {
|
|
29
|
+
if (hasIndexedDB()) {
|
|
30
|
+
return 'indexedDB';
|
|
31
|
+
}
|
|
32
|
+
else if (hasLocalStorage()) {
|
|
33
|
+
return 'localStorage';
|
|
34
|
+
}
|
|
35
|
+
return 'memory';
|
|
36
|
+
}
|
|
37
|
+
async fetchPrice(symbol, currency = 'usd') {
|
|
38
|
+
const cacheKey = `${symbol}-${currency}`;
|
|
39
|
+
// Check cache first
|
|
40
|
+
const cached = await this.cache.get(cacheKey);
|
|
41
|
+
if (cached) {
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
// Use rate limiter to fetch from providers
|
|
45
|
+
return this.rateLimiter.execute(cacheKey, async () => {
|
|
46
|
+
const results = await this.fetchFromProviders(symbol, currency);
|
|
47
|
+
if (results.length === 0) {
|
|
48
|
+
throw new Error(`No price data available for ${symbol}`);
|
|
49
|
+
}
|
|
50
|
+
// Calculate consensus price
|
|
51
|
+
const consensusPrice = this.calculateConsensusPrice(results);
|
|
52
|
+
const priceData = {
|
|
53
|
+
symbol: symbol.toUpperCase(),
|
|
54
|
+
price: consensusPrice,
|
|
55
|
+
timestamp: new Date()
|
|
56
|
+
};
|
|
57
|
+
// Cache the result
|
|
58
|
+
await this.cache.set(cacheKey, priceData);
|
|
59
|
+
return priceData;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async fetchMultiplePrices(symbols, currency = 'usd') {
|
|
63
|
+
// Check cache for all symbols
|
|
64
|
+
const results = [];
|
|
65
|
+
const uncachedSymbols = [];
|
|
66
|
+
for (const symbol of symbols) {
|
|
67
|
+
const cached = await this.cache.get(`${symbol}-${currency}`);
|
|
68
|
+
if (cached) {
|
|
69
|
+
results.push(cached);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
uncachedSymbols.push(symbol);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Fetch uncached symbols
|
|
76
|
+
if (uncachedSymbols.length > 0) {
|
|
77
|
+
const batchKey = `batch-${uncachedSymbols.join(',')}-${currency}`;
|
|
78
|
+
const fetchedPrices = await this.rateLimiter.execute(batchKey, async () => {
|
|
79
|
+
const allResults = [];
|
|
80
|
+
// Try each provider
|
|
81
|
+
for (const provider of this.providers) {
|
|
82
|
+
try {
|
|
83
|
+
const providerResults = await provider.fetchMultiplePrices(uncachedSymbols, currency);
|
|
84
|
+
allResults.push(...providerResults);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
console.warn(`Provider failed:`, error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Group by symbol and calculate consensus
|
|
91
|
+
const grouped = this.groupBySymbol(allResults);
|
|
92
|
+
const consensusPrices = [];
|
|
93
|
+
for (const [symbol, prices] of grouped.entries()) {
|
|
94
|
+
if (prices.length > 0) {
|
|
95
|
+
const consensusPrice = this.calculateConsensusPrice(prices);
|
|
96
|
+
const priceData = {
|
|
97
|
+
symbol,
|
|
98
|
+
price: consensusPrice,
|
|
99
|
+
timestamp: new Date()
|
|
100
|
+
};
|
|
101
|
+
consensusPrices.push(priceData);
|
|
102
|
+
// Cache individual results
|
|
103
|
+
await this.cache.set(`${symbol}-${currency}`, priceData);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return consensusPrices;
|
|
107
|
+
});
|
|
108
|
+
results.push(...fetchedPrices);
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
async fetchFromProviders(symbol, currency) {
|
|
113
|
+
const results = [];
|
|
114
|
+
// Try each provider sequentially with fallback
|
|
115
|
+
for (const provider of this.providers) {
|
|
116
|
+
try {
|
|
117
|
+
const price = await provider.fetchPrice(symbol, currency);
|
|
118
|
+
results.push(price);
|
|
119
|
+
// If we have enough providers for consensus, we can stop
|
|
120
|
+
if (results.length >= Math.ceil(this.providers.length * this.consensusThreshold)) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.warn(`Provider failed for ${symbol}:`, error);
|
|
126
|
+
// Continue to next provider
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
calculateConsensusPrice(prices) {
|
|
132
|
+
if (prices.length === 0) {
|
|
133
|
+
throw new Error('No prices available for consensus');
|
|
134
|
+
}
|
|
135
|
+
if (prices.length === 1) {
|
|
136
|
+
return prices[0].price;
|
|
137
|
+
}
|
|
138
|
+
// Sort prices
|
|
139
|
+
const sortedPrices = prices.map(p => p.price).sort((a, b) => a - b);
|
|
140
|
+
// Remove outliers (prices that deviate more than 10% from median)
|
|
141
|
+
const median = sortedPrices[Math.floor(sortedPrices.length / 2)];
|
|
142
|
+
const filtered = sortedPrices.filter(price => Math.abs(price - median) / median <= 0.1);
|
|
143
|
+
// If too many outliers, use median
|
|
144
|
+
if (filtered.length < prices.length * this.consensusThreshold) {
|
|
145
|
+
return median;
|
|
146
|
+
}
|
|
147
|
+
// Calculate average of filtered prices
|
|
148
|
+
return filtered.reduce((sum, price) => sum + price, 0) / filtered.length;
|
|
149
|
+
}
|
|
150
|
+
groupBySymbol(prices) {
|
|
151
|
+
const grouped = new Map();
|
|
152
|
+
for (const price of prices) {
|
|
153
|
+
const symbol = price.symbol.toUpperCase();
|
|
154
|
+
if (!grouped.has(symbol)) {
|
|
155
|
+
grouped.set(symbol, []);
|
|
156
|
+
}
|
|
157
|
+
grouped.get(symbol).push(price);
|
|
158
|
+
}
|
|
159
|
+
return grouped;
|
|
160
|
+
}
|
|
161
|
+
// Utility methods for managing the aggregator
|
|
162
|
+
addProvider(provider) {
|
|
163
|
+
this.providers.push(provider);
|
|
164
|
+
}
|
|
165
|
+
removeProvider(index) {
|
|
166
|
+
this.providers.splice(index, 1);
|
|
167
|
+
}
|
|
168
|
+
async clearCache() {
|
|
169
|
+
await this.cache.clear();
|
|
170
|
+
}
|
|
171
|
+
resetRateLimiter() {
|
|
172
|
+
this.rateLimiter.reset();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function isBrowser() {
|
|
2
|
+
return typeof globalThis !== 'undefined' &&
|
|
3
|
+
typeof globalThis.window !== 'undefined' &&
|
|
4
|
+
typeof globalThis.window.document !== 'undefined';
|
|
5
|
+
}
|
|
6
|
+
export function hasIndexedDB() {
|
|
7
|
+
return isBrowser() && 'indexedDB' in globalThis.window;
|
|
8
|
+
}
|
|
9
|
+
export function hasLocalStorage() {
|
|
10
|
+
try {
|
|
11
|
+
return isBrowser() &&
|
|
12
|
+
'localStorage' in globalThis.window &&
|
|
13
|
+
globalThis.window.localStorage !== null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
data: T;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
ttl: number;
|
|
5
|
+
}
|
|
6
|
+
export interface EdgeCacheOptions {
|
|
7
|
+
defaultTTL?: number;
|
|
8
|
+
maxSize?: number;
|
|
9
|
+
storage?: 'memory' | 'localStorage' | 'indexedDB';
|
|
10
|
+
}
|
|
11
|
+
export declare class EdgeCache {
|
|
12
|
+
private memoryCache;
|
|
13
|
+
private options;
|
|
14
|
+
private dbName;
|
|
15
|
+
private storeName;
|
|
16
|
+
private db;
|
|
17
|
+
constructor(options?: EdgeCacheOptions);
|
|
18
|
+
private initIndexedDB;
|
|
19
|
+
get<T>(key: string): Promise<T | null>;
|
|
20
|
+
set<T>(key: string, data: T, ttl?: number): Promise<void>;
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
clear(): Promise<void>;
|
|
23
|
+
private evictOldest;
|
|
24
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { hasIndexedDB, hasLocalStorage } from './browser-detect.js';
|
|
2
|
+
export class EdgeCache {
|
|
3
|
+
constructor(options = {}) {
|
|
4
|
+
this.memoryCache = new Map();
|
|
5
|
+
this.dbName = 'asset-valuator-cache';
|
|
6
|
+
this.storeName = 'prices';
|
|
7
|
+
this.db = null;
|
|
8
|
+
this.options = {
|
|
9
|
+
defaultTTL: options.defaultTTL || 60000, // 60 seconds
|
|
10
|
+
maxSize: options.maxSize || 1000,
|
|
11
|
+
storage: options.storage || 'memory'
|
|
12
|
+
};
|
|
13
|
+
if (this.options.storage === 'indexedDB' && hasIndexedDB()) {
|
|
14
|
+
this.initIndexedDB();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async initIndexedDB() {
|
|
18
|
+
try {
|
|
19
|
+
const request = globalThis.window.indexedDB.open(this.dbName, 1);
|
|
20
|
+
request.onupgradeneeded = (event) => {
|
|
21
|
+
const db = event.target.result;
|
|
22
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
23
|
+
db.createObjectStore(this.storeName, { keyPath: 'key' });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
this.db = await new Promise((resolve, reject) => {
|
|
27
|
+
request.onsuccess = () => resolve(request.result);
|
|
28
|
+
request.onerror = () => reject(request.error);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.warn('IndexedDB initialization failed, falling back to memory cache', error);
|
|
33
|
+
this.options.storage = 'memory';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async get(key) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
switch (this.options.storage) {
|
|
39
|
+
case 'localStorage':
|
|
40
|
+
if (hasLocalStorage()) {
|
|
41
|
+
try {
|
|
42
|
+
const stored = localStorage.getItem(`cache_${key}`);
|
|
43
|
+
if (stored) {
|
|
44
|
+
const entry = JSON.parse(stored);
|
|
45
|
+
if (now - entry.timestamp < entry.ttl) {
|
|
46
|
+
return entry.data;
|
|
47
|
+
}
|
|
48
|
+
localStorage.removeItem(`cache_${key}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.warn('localStorage read failed', error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
case 'indexedDB':
|
|
57
|
+
if (this.db) {
|
|
58
|
+
try {
|
|
59
|
+
const transaction = this.db.transaction([this.storeName], 'readonly');
|
|
60
|
+
const store = transaction.objectStore(this.storeName);
|
|
61
|
+
const request = store.get(key);
|
|
62
|
+
const result = await new Promise((resolve, reject) => {
|
|
63
|
+
request.onsuccess = () => resolve(request.result);
|
|
64
|
+
request.onerror = () => reject(request.error);
|
|
65
|
+
});
|
|
66
|
+
if (result && now - result.timestamp < result.ttl) {
|
|
67
|
+
return result.data;
|
|
68
|
+
}
|
|
69
|
+
// Clean up expired entry
|
|
70
|
+
if (result) {
|
|
71
|
+
this.delete(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.warn('IndexedDB read failed', error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
default: // memory
|
|
80
|
+
const entry = this.memoryCache.get(key);
|
|
81
|
+
if (entry && now - entry.timestamp < entry.ttl) {
|
|
82
|
+
return entry.data;
|
|
83
|
+
}
|
|
84
|
+
if (entry) {
|
|
85
|
+
this.memoryCache.delete(key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
async set(key, data, ttl) {
|
|
91
|
+
const entry = {
|
|
92
|
+
data,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
ttl: ttl || this.options.defaultTTL
|
|
95
|
+
};
|
|
96
|
+
switch (this.options.storage) {
|
|
97
|
+
case 'localStorage':
|
|
98
|
+
if (hasLocalStorage()) {
|
|
99
|
+
try {
|
|
100
|
+
localStorage.setItem(`cache_${key}`, JSON.stringify(entry));
|
|
101
|
+
// Enforce max size for localStorage
|
|
102
|
+
if (localStorage.length > this.options.maxSize) {
|
|
103
|
+
this.evictOldest();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.warn('localStorage write failed', error);
|
|
108
|
+
// Fall back to memory cache
|
|
109
|
+
this.memoryCache.set(key, entry);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case 'indexedDB':
|
|
114
|
+
if (this.db) {
|
|
115
|
+
try {
|
|
116
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
117
|
+
const store = transaction.objectStore(this.storeName);
|
|
118
|
+
await new Promise((resolve, reject) => {
|
|
119
|
+
const request = store.put({ key, ...entry });
|
|
120
|
+
request.onsuccess = () => resolve();
|
|
121
|
+
request.onerror = () => reject(request.error);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.warn('IndexedDB write failed', error);
|
|
126
|
+
// Fall back to memory cache
|
|
127
|
+
this.memoryCache.set(key, entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
default: // memory
|
|
132
|
+
this.memoryCache.set(key, entry);
|
|
133
|
+
// Enforce max size for memory cache
|
|
134
|
+
if (this.memoryCache.size > this.options.maxSize) {
|
|
135
|
+
const firstKey = this.memoryCache.keys().next().value;
|
|
136
|
+
this.memoryCache.delete(firstKey);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async delete(key) {
|
|
141
|
+
switch (this.options.storage) {
|
|
142
|
+
case 'localStorage':
|
|
143
|
+
if (hasLocalStorage()) {
|
|
144
|
+
localStorage.removeItem(`cache_${key}`);
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
case 'indexedDB':
|
|
148
|
+
if (this.db) {
|
|
149
|
+
try {
|
|
150
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
151
|
+
const store = transaction.objectStore(this.storeName);
|
|
152
|
+
store.delete(key);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.warn('IndexedDB delete failed', error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
this.memoryCache.delete(key);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async clear() {
|
|
164
|
+
switch (this.options.storage) {
|
|
165
|
+
case 'localStorage':
|
|
166
|
+
if (hasLocalStorage()) {
|
|
167
|
+
const keys = Object.keys(localStorage);
|
|
168
|
+
keys.forEach(key => {
|
|
169
|
+
if (key.startsWith('cache_')) {
|
|
170
|
+
localStorage.removeItem(key);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case 'indexedDB':
|
|
176
|
+
if (this.db) {
|
|
177
|
+
try {
|
|
178
|
+
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
|
179
|
+
const store = transaction.objectStore(this.storeName);
|
|
180
|
+
store.clear();
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
console.warn('IndexedDB clear failed', error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
default:
|
|
188
|
+
this.memoryCache.clear();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
evictOldest() {
|
|
192
|
+
if (hasLocalStorage()) {
|
|
193
|
+
const cacheKeys = Object.keys(localStorage)
|
|
194
|
+
.filter(key => key.startsWith('cache_'))
|
|
195
|
+
.map(key => {
|
|
196
|
+
try {
|
|
197
|
+
const entry = JSON.parse(localStorage.getItem(key) || '{}');
|
|
198
|
+
return { key, timestamp: entry.timestamp || 0 };
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return { key, timestamp: 0 };
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
205
|
+
// Remove oldest 10% of entries
|
|
206
|
+
const toRemove = Math.ceil(cacheKeys.length * 0.1);
|
|
207
|
+
cacheKeys.slice(0, toRemove).forEach(({ key }) => {
|
|
208
|
+
localStorage.removeItem(key);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface RateLimiterOptions {
|
|
2
|
+
maxRequests: number;
|
|
3
|
+
windowMs: number;
|
|
4
|
+
retryAfterMs?: number;
|
|
5
|
+
maxRetries?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class RateLimiter {
|
|
8
|
+
private options;
|
|
9
|
+
private requests;
|
|
10
|
+
private retryCount;
|
|
11
|
+
constructor(options: RateLimiterOptions);
|
|
12
|
+
execute<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
13
|
+
private sleep;
|
|
14
|
+
private is429Error;
|
|
15
|
+
reset(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
this.options = options;
|
|
4
|
+
this.requests = [];
|
|
5
|
+
this.retryCount = new Map();
|
|
6
|
+
this.options.retryAfterMs = options.retryAfterMs || 1000;
|
|
7
|
+
this.options.maxRetries = options.maxRetries || 3;
|
|
8
|
+
}
|
|
9
|
+
async execute(key, fn) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
// Clean up old requests
|
|
12
|
+
this.requests = this.requests.filter(time => now - time < this.options.windowMs);
|
|
13
|
+
// Check if we're at the limit
|
|
14
|
+
if (this.requests.length >= this.options.maxRequests) {
|
|
15
|
+
const retries = this.retryCount.get(key) || 0;
|
|
16
|
+
if (retries >= this.options.maxRetries) {
|
|
17
|
+
this.retryCount.delete(key);
|
|
18
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
19
|
+
}
|
|
20
|
+
// Exponential backoff
|
|
21
|
+
const backoffMs = this.options.retryAfterMs * Math.pow(2, retries);
|
|
22
|
+
await this.sleep(backoffMs);
|
|
23
|
+
this.retryCount.set(key, retries + 1);
|
|
24
|
+
return this.execute(key, fn);
|
|
25
|
+
}
|
|
26
|
+
// Execute the function
|
|
27
|
+
this.requests.push(now);
|
|
28
|
+
this.retryCount.delete(key);
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// If it's a 429 error, apply backoff
|
|
34
|
+
if (this.is429Error(error)) {
|
|
35
|
+
const retries = this.retryCount.get(key) || 0;
|
|
36
|
+
if (retries >= this.options.maxRetries) {
|
|
37
|
+
this.retryCount.delete(key);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
const backoffMs = this.options.retryAfterMs * Math.pow(2, retries);
|
|
41
|
+
await this.sleep(backoffMs);
|
|
42
|
+
this.retryCount.set(key, retries + 1);
|
|
43
|
+
return this.execute(key, fn);
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
sleep(ms) {
|
|
49
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
50
|
+
}
|
|
51
|
+
is429Error(error) {
|
|
52
|
+
return error?.response?.status === 429 ||
|
|
53
|
+
error?.code === 'ERR_RATE_LIMITED' ||
|
|
54
|
+
(error?.message && error.message.includes('429'));
|
|
55
|
+
}
|
|
56
|
+
reset() {
|
|
57
|
+
this.requests = [];
|
|
58
|
+
this.retryCount.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cygnus-wealth/asset-valuator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Asset valuation library for retrieving and converting cryptocurrency prices",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"eslint": "^9.32.0",
|
|
41
41
|
"jest": "^30.0.5",
|
|
42
42
|
"ts-jest": "^29.4.0",
|
|
43
|
+
"tsx": "^4.7.0",
|
|
43
44
|
"typescript": "^5.8.3"
|
|
44
45
|
}
|
|
45
46
|
}
|