/**
* Warframe Market API v2 Client
* Main entry point for interacting with the Warframe Market API v2
*/
import HttpClient from './utils/http.js';
import VersionedCache from './utils/cache.js';
import Item from './models/Item.js';
import Order from './models/Order.js';
import Summary from './models/Summary.js';
import { calculateStatistics, getBestOrders, formatPriceRange, formatStatistics } from './utils/statistics.js';
import { normalizeLanguage } from './utils/i18n.js';
import { PLATFORMS, SHORT_CACHE_TTL, API_BASE_URL } from './constants.js';
export default class MarketFetcherV2 {
// Private fields
#http;
#cache;
#ordersCache;
#ordersCacheMaxSize;
/**
* @param {Object} options - Fetcher options
* @param {string} [options.locale='en'] - Default language
* @param {number} [options.timeout=5000] - Request timeout in ms
* @param {Object} [options.logger] - Logger instance
* @param {number} [options.cacheSize=100] - Cache size
* @param {number} [options.cacheTTL] - Cache TTL in ms
*/
constructor(options = {}) {
this.locale = normalizeLanguage(options.locale || 'en');
this.logger = options.logger || console;
this.#ordersCacheMaxSize = options.ordersCacheSize || 50;
this.#http = new HttpClient({
baseURL: options.baseURL || API_BASE_URL,
locale: this.locale,
timeout: options.timeout,
logger: this.logger,
});
this.#cache = new VersionedCache({
maxSize: options.cacheSize || 100,
ttl: options.cacheTTL,
});
// Short-lived cache for orders (1 minute)
this.#ordersCache = new Map();
}
// ==========================================================================
// ITEMS
// ==========================================================================
/**
* Get list of all tradable items
* @returns {Promise<Item[]>}
*/
async getItems() {
const cachedItems = await this.#cache.get('items', 'items', async () => {
const data = await this.#http.get('/items', { locale: this.locale });
// Store raw data, not Item instances, to support persistence
return data;
});
// If cached items are already Item instances, return them
if (cachedItems.length > 0 && cachedItems[0] instanceof Item) {
return cachedItems;
}
// Otherwise, reconstruct Item instances from raw data
return cachedItems.map((item) => new Item(item, this.locale));
}
/**
* Get item by slug
* @param {string} slug - Item slug
* @returns {Promise<Item>}
*/
async getItemBySlug(slug) {
const cacheKey = `item_${slug}`;
const cachedItem = await this.#cache.get(cacheKey, 'items', async () => {
const data = await this.#http.get(`/item/${slug}`, { locale: this.locale });
// Store raw data, not Item instance, to support persistence
return data;
});
// If cached item is already an Item instance, return it
if (cachedItem instanceof Item) {
return cachedItem;
}
// Otherwise, reconstruct Item instance from raw data
return new Item(cachedItem, this.locale);
}
/**
* Get all items in a set
* @param {string} slug - Item slug
* @returns {Promise<{id: string, items: Item[]}>}
*/
async getItemSet(slug) {
const cacheKey = `item_set_${slug}`;
return this.#cache.get(cacheKey, 'items', async () => {
const data = await this.#http.get(`/item/${slug}/set`, { locale: this.locale });
return {
id: data.id,
items: data.items.map((item) => new Item(item, this.locale)),
};
});
}
// ==========================================================================
// ORDERS
// ==========================================================================
/**
* Get top orders for an item (RECOMMENDED for price checks)
* @param {string} slug - Item slug
* @param {Object} options - Options
* @param {string} options.platform - Platform (pc, ps4, xbox, switch)
* @param {number} [options.rank] - Filter by rank
* @param {number} [options.rankLt] - Filter by rank less than
* @param {number} [options.charges] - Filter by charges
* @param {number} [options.chargesLt] - Filter by charges less than
* @param {number} [options.amberStars] - Filter by amber stars
* @param {number} [options.amberStarsLt] - Filter by amber stars less than
* @param {number} [options.cyanStars] - Filter by cyan stars
* @param {number} [options.cyanStarsLt] - Filter by cyan stars less than
* @param {string} [options.subtype] - Filter by subtype
* @returns {Promise<{buy: Order[], sell: Order[]}>}
*/
async getTopOrders(slug, options = {}) {
const { platform, ...filters } = options;
if (!platform) {
throw new Error('Platform is required');
}
const cacheKey = `top_orders_${slug}_${platform}_${this.#stableStringify(filters)}`;
// Check short-lived cache
const cached = this.#getOrdersFromCache(cacheKey);
if (cached) return cached;
const data = await this.#http.get(`/orders/item/${slug}/top`, {
platform,
query: filters,
});
const result = {
buy: data.buy.map((order) => new Order(order)),
sell: data.sell.map((order) => new Order(order)),
};
this.#setOrdersCache(cacheKey, result);
return result;
}
/**
* Get all orders for an item
* @param {string} slug - Item slug
* @param {string} platform - Platform (pc, ps4, xbox, switch)
* @returns {Promise<Order[]>}
*/
async getAllOrders(slug, platform) {
if (!platform) {
throw new Error('Platform is required');
}
const cacheKey = `all_orders_${slug}_${platform}`;
// Check short-lived cache
const cached = this.#getOrdersFromCache(cacheKey);
if (cached) return cached;
const data = await this.#http.get(`/orders/item/${slug}`, { platform });
const result = data.map((order) => new Order(order));
this.#setOrdersCache(cacheKey, result);
return result;
}
/**
* Get recent orders (last 4 hours)
* @param {string} platform - Platform (pc, ps4, xbox, switch)
* @returns {Promise<Order[]>}
*/
async getRecentOrders(platform) {
if (!platform) {
throw new Error('Platform is required');
}
const cacheKey = `recent_orders_${platform}`;
// Check short-lived cache
const cached = this.#getOrdersFromCache(cacheKey);
if (cached) return cached;
const data = await this.#http.get('/orders/recent', { platform });
const result = data.map((order) => new Order(order));
this.#setOrdersCache(cacheKey, result);
return result;
}
// ==========================================================================
// STATISTICS (Client-side calculation)
// ==========================================================================
/**
* Calculate statistics from orders
* @param {Order[]} orders - Orders array
* @param {Object} options - Calculation options
* @returns {Object} Statistics
*/
calculateStatistics(orders, options = {}) {
return calculateStatistics(orders, options);
}
/**
* Get best orders (sorted)
* @param {Order[]} orders - Orders array
* @param {Object} options - Options
* @returns {{buy: Order[], sell: Order[]}}
*/
getBestOrders(orders, options = {}) {
return getBestOrders(orders, options);
}
/**
* Format price range string
* @param {Object} stats - Statistics object
* @returns {string}
*/
formatPriceRange(stats) {
return formatPriceRange(stats);
}
/**
* Format statistics for display
* @param {Object} stats - Statistics object
* @param {string} type - Order type
* @returns {string}
*/
formatStatistics(stats, type) {
return formatStatistics(stats, type);
}
// ==========================================================================
// SEARCH / QUERY (v1 compatibility)
// ==========================================================================
/**
* Query market for item (v1 compatibility method)
* @param {string} query - Search query
* @param {Object} options - Query options
* @param {string} options.platform - Platform
* @param {function} [options.successfulQuery] - Callback on success
* @returns {Promise<Summary[]>}
*/
async queryMarket(query, options = {}) {
const { platform, successfulQuery } = options;
if (!platform) {
throw new Error('Platform is required');
}
// Search for item
const items = await this.getItems();
const normalizedQuery = query.toLowerCase().trim();
// Fuzzy search
const matches = items.filter((item) => {
const name = item.name.toLowerCase();
const slug = item.slug.toLowerCase();
return name.includes(normalizedQuery) || slug.includes(normalizedQuery);
});
if (matches.length === 0) {
throw new Error(`No items found for query: ${query}`);
}
// Use first match - but only use slug for now
const matchedSlug = matches[0].slug;
// Fetch full item data with all properties (tradingTax, ducats, etc.)
const item = await this.getItemBySlug(matchedSlug);
// Get top orders for the item
const topOrders = await this.getTopOrders(item.slug, { platform });
// Calculate statistics
const sellStats = this.calculateStatistics(topOrders.sell, {
type: 'sell',
onlineOnly: true,
});
const buyStats = this.calculateStatistics(topOrders.buy, {
type: 'buy',
onlineOnly: true,
});
// Call success callback if provided
if (successfulQuery && typeof successfulQuery === 'function') {
successfulQuery();
}
// Create Summary object for v1 compatibility
const summary = new Summary(item, topOrders, {
sell: sellStats,
buy: buyStats,
});
// Return array of summaries (v1 compatibility)
return [summary];
}
/**
* Price check query (v1 compatibility method)
* @param {string} query - Search query
* @param {string} platform - Platform
* @returns {Promise<Object>}
*/
async priceCheckQuery(query, platform) {
return this.queryMarket(query, { platform });
}
// ==========================================================================
// CACHE MANAGEMENT
// ==========================================================================
/**
* Get from short-lived orders cache
* @param {string} key - Cache key
* @returns {*|null}
* @private
*/
#getOrdersFromCache(key) {
const cached = this.#ordersCache.get(key);
if (!cached) return null;
// Check TTL (1 minute for orders)
if (Date.now() - cached.timestamp > SHORT_CACHE_TTL) {
this.#ordersCache.delete(key);
return null;
}
return cached.value;
}
/**
* Set short-lived orders cache
* @param {string} key - Cache key
* @param {*} value - Value to cache
* @private
*/
#setOrdersCache(key, value) {
// Evict oldest entries if at capacity
if (this.#ordersCache.size >= this.#ordersCacheMaxSize) {
const oldestKey = this.#ordersCache.keys().next().value;
this.#ordersCache.delete(oldestKey);
}
this.#ordersCache.set(key, {
value,
timestamp: Date.now(),
});
}
/**
* Clear all caches
*/
clearCache() {
this.#cache.clear();
this.#ordersCache.clear();
}
/**
* Check API versions
* @returns {Promise<Object>}
*/
async checkVersions() {
return this.#cache.checkVersions();
}
/**
* Stable stringify for cache keys (sorts object keys recursively)
* @param {*} value - Value to stringify
* @returns {string} Deterministic JSON string
* @private
*/
#stableStringify(value) {
if (value === null || value === undefined) {
return JSON.stringify(value);
}
if (typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => this.#stableStringify(item)).join(',')}]`;
}
// Sort object keys and recursively stringify
const sortedKeys = Object.keys(value).sort();
const pairs = sortedKeys.map((key) => `${JSON.stringify(key)}:${this.#stableStringify(value[key])}`);
return `{${pairs.join(',')}}`;
}
// ==========================================================================
// UTILITY
// ==========================================================================
/**
* Normalize platform
* @param {string} platform - Platform alias
* @returns {string} Normalized platform
*/
normalizePlatform(platform) {
return PLATFORMS[platform?.toLowerCase()] || platform;
}
/**
* Set locale
* @param {string} locale - Language code
*/
setLocale(locale) {
this.locale = normalizeLanguage(locale);
this.#http.setLocale(locale);
}
/**
* Stop and cleanup (clears all caches)
*/
stop() {
this.clearCache();
}
}