@oxyhq/services
Version:
Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀
476 lines (440 loc) • 15.1 kB
JavaScript
"use strict";
/**
* Unified HTTP Service
*
* Consolidates HttpClient + RequestManager into a single efficient class.
* Uses native fetch instead of axios for smaller bundle size.
*
* Handles:
* - Authentication (token management, auto-refresh)
* - Caching (TTL-based)
* - Deduplication (concurrent requests)
* - Retry logic
* - Error handling
* - Request queuing
*/
import { TTLCache, registerCacheForCleanup } from '../utils/cache';
import { RequestDeduplicator, RequestQueue, SimpleLogger } from '../utils/requestUtils';
import { retryAsync } from '../utils/asyncUtils';
import { handleHttpError } from '../utils/errorUtils';
import { jwtDecode } from 'jwt-decode';
/**
* Token store for authentication (singleton)
*/
class TokenStore {
accessToken = null;
refreshToken = null;
constructor() {}
static getInstance() {
if (!TokenStore.instance) {
TokenStore.instance = new TokenStore();
}
return TokenStore.instance;
}
setTokens(accessToken, refreshToken = '') {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
getAccessToken() {
return this.accessToken;
}
getRefreshToken() {
return this.refreshToken;
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
}
hasAccessToken() {
return !!this.accessToken;
}
}
/**
* Unified HTTP Service
*
* Consolidates HttpClient + RequestManager into a single efficient class.
* Uses native fetch instead of axios for smaller bundle size.
*/
export class HttpService {
// Performance monitoring
requestMetrics = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
cacheHits: 0,
cacheMisses: 0,
averageResponseTime: 0
};
constructor(config) {
this.config = config;
this.baseURL = config.baseURL;
this.tokenStore = TokenStore.getInstance();
this.logger = new SimpleLogger(config.enableLogging || false, config.logLevel || 'error', 'HttpService');
// Initialize performance infrastructure
this.cache = new TTLCache(config.cacheTTL || 5 * 60 * 1000);
registerCacheForCleanup(this.cache);
this.deduplicator = new RequestDeduplicator();
this.requestQueue = new RequestQueue(config.maxConcurrentRequests || 10, config.requestQueueSize || 100);
}
/**
* Main request method - handles everything in one place
*/
async request(config) {
const {
method,
url,
data,
params,
timeout = this.config.requestTimeout || 5000,
signal,
cache = method === 'GET',
cacheTTL,
deduplicate = true,
retry = this.config.enableRetry !== false,
maxRetries = this.config.maxRetries || 3
} = config;
// Generate cache key (optimized for large objects)
const cacheKey = cache ? this.generateCacheKey(method, url, data || params) : null;
// Check cache first
if (cache && cacheKey) {
const cached = this.cache.get(cacheKey);
if (cached !== null) {
this.requestMetrics.cacheHits++;
this.logger.debug('Cache hit:', url);
return cached;
}
this.requestMetrics.cacheMisses++;
}
// Request function
const requestFn = async () => {
const startTime = Date.now();
try {
// Build URL with params
const fullUrl = this.buildURL(url, params);
// Get auth token (with auto-refresh)
const authHeader = await this.getAuthHeader();
// Determine if data is FormData
const isFormData = data instanceof FormData;
// Make fetch request
const controller = new AbortController();
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
if (signal) {
signal.addEventListener('abort', () => controller.abort());
}
// Build headers
const headers = {
'Accept': 'application/json'
};
// Only set Content-Type for non-FormData requests (FormData sets it automatically with boundary)
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(fullUrl, {
method,
headers,
body: method !== 'GET' && data ? isFormData ? data : JSON.stringify(data) : undefined,
signal: controller.signal
});
if (timeoutId) clearTimeout(timeoutId);
// Handle response
if (!response.ok) {
if (response.status === 401) {
this.tokenStore.clearTokens();
}
// Try to parse error response (handle empty/malformed JSON)
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
try {
const errorData = await response.json();
if (errorData?.message) {
errorMessage = errorData.message;
}
} catch (parseError) {
// Malformed JSON or empty response - use status text
this.logger.warn('Failed to parse error response JSON:', parseError);
}
}
const error = new Error(errorMessage);
error.status = response.status;
error.response = {
status: response.status,
statusText: response.statusText
};
throw error;
}
// Handle different response types (optimized - read response once)
const contentType = response.headers.get('content-type');
let responseData;
if (contentType && contentType.includes('application/json')) {
// Use response.json() directly for better performance
try {
responseData = await response.json();
// Handle null/undefined responses
if (responseData === null || responseData === undefined) {
responseData = null;
} else {
// Unwrap standardized API response format for JSON
responseData = this.unwrapResponse(responseData);
}
} catch (parseError) {
// Handle malformed JSON or empty responses gracefully
// Note: Once response.json() is called, the body is consumed and cannot be read again
// So we check the error type to determine if it's empty or malformed
if (parseError instanceof SyntaxError) {
this.logger.warn('Failed to parse JSON response (malformed or empty):', parseError);
// SyntaxError typically means empty or malformed JSON
// For empty responses, return null; for malformed JSON, throw descriptive error
responseData = null; // Treat as empty response for safety
} else {
this.logger.warn('Failed to read response:', parseError);
throw new Error('Failed to read response from server');
}
}
} else if (contentType && (contentType.includes('application/octet-stream') || contentType.includes('image/') || contentType.includes('video/') || contentType.includes('audio/'))) {
// For binary responses (blobs), return the blob directly without unwrapping
responseData = await response.blob();
} else {
// For other responses, return as text
const text = await response.text();
responseData = text || null;
}
const duration = Date.now() - startTime;
this.updateMetrics(true, duration);
this.config.onRequestEnd?.(url, method, duration, true);
return responseData;
} catch (error) {
const duration = Date.now() - startTime;
this.updateMetrics(false, duration);
this.config.onRequestEnd?.(url, method, duration, false);
this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
// Handle AbortError specifically for better error messages
if (error instanceof Error && error.name === 'AbortError') {
throw handleHttpError(error);
}
throw handleHttpError(error);
}
};
// Wrap with retry if enabled
const requestWithRetry = retry ? () => retryAsync(requestFn, maxRetries, this.config.retryDelay || 1000) : requestFn;
// Wrap with deduplication if enabled (use optimized key generation)
const dedupeKey = deduplicate ? this.generateCacheKey(method, url, data || params) : null;
const finalRequest = dedupeKey ? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry) : requestWithRetry;
// Execute request (with queue if needed)
const result = await this.requestQueue.enqueue(finalRequest);
// Cache the result if caching is enabled
if (cache && cacheKey && result) {
this.cache.set(cacheKey, result, cacheTTL);
}
return result;
}
/**
* Generate cache key efficiently
* Uses simple hash for large objects to avoid expensive JSON.stringify
*/
generateCacheKey(method, url, data) {
if (!data || typeof data === 'object' && Object.keys(data).length === 0) {
return `${method}:${url}`;
}
// For small objects, use JSON.stringify
const dataStr = JSON.stringify(data);
if (dataStr.length < 1000) {
return `${method}:${url}:${dataStr}`;
}
// For large objects, use a simple hash based on keys and values length
// This avoids expensive serialization while still being unique enough
const hash = typeof data === 'object' && data !== null ? Object.keys(data).sort().join(',') + ':' + dataStr.length : String(data).substring(0, 100);
return `${method}:${url}:${hash}`;
}
/**
* Build full URL with query params
*/
buildURL(url, params) {
const base = url.startsWith('http') ? url : `${this.baseURL}${url}`;
if (!params || Object.keys(params).length === 0) {
return base;
}
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
}
/**
* Get auth header with automatic token refresh
*/
async getAuthHeader() {
const accessToken = this.tokenStore.getAccessToken();
if (!accessToken) {
return null;
}
try {
const decoded = jwtDecode(accessToken);
const currentTime = Math.floor(Date.now() / 1000);
// If token expires in less than 60 seconds, refresh it
if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
try {
const refreshUrl = `${this.baseURL}/api/session/token/${decoded.sessionId}`;
// Use AbortSignal.timeout for consistent timeout handling
const response = await fetch(refreshUrl, {
method: 'GET',
headers: {
'Accept': 'application/json'
},
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
const {
accessToken: newToken
} = await response.json();
this.tokenStore.setTokens(newToken);
this.logger.debug('Token refreshed');
return `Bearer ${newToken}`;
}
} catch (refreshError) {
this.logger.warn('Token refresh failed, using current token');
}
}
return `Bearer ${accessToken}`;
} catch (error) {
this.logger.error('Error processing token:', error);
return `Bearer ${accessToken}`;
}
}
/**
* Unwrap standardized API response format
*/
unwrapResponse(responseData) {
// Handle paginated responses: { data: [...], pagination: {...} }
if (responseData && typeof responseData === 'object' && 'data' in responseData && 'pagination' in responseData) {
return responseData;
}
// Handle regular success responses: { data: ... }
if (responseData && typeof responseData === 'object' && 'data' in responseData && !Array.isArray(responseData)) {
return responseData.data;
}
// Return as-is for responses that don't use sendSuccess wrapper
return responseData;
}
/**
* Update request metrics
*/
updateMetrics(success, duration) {
this.requestMetrics.totalRequests++;
if (success) {
this.requestMetrics.successfulRequests++;
} else {
this.requestMetrics.failedRequests++;
}
const alpha = 0.1;
this.requestMetrics.averageResponseTime = this.requestMetrics.averageResponseTime * (1 - alpha) + duration * alpha;
}
// Convenience methods (for backward compatibility)
async get(url, config) {
const result = await this.request({
method: 'GET',
url,
...config
});
return {
data: result
};
}
async post(url, data, config) {
const result = await this.request({
method: 'POST',
url,
data,
...config
});
return {
data: result
};
}
async put(url, data, config) {
const result = await this.request({
method: 'PUT',
url,
data,
...config
});
return {
data: result
};
}
async patch(url, data, config) {
const result = await this.request({
method: 'PATCH',
url,
data,
...config
});
return {
data: result
};
}
async delete(url, config) {
const result = await this.request({
method: 'DELETE',
url,
...config
});
return {
data: result
};
}
// Token management
setTokens(accessToken, refreshToken = '') {
this.tokenStore.setTokens(accessToken, refreshToken);
}
clearTokens() {
this.tokenStore.clearTokens();
}
getAccessToken() {
return this.tokenStore.getAccessToken();
}
hasAccessToken() {
return this.tokenStore.hasAccessToken();
}
getBaseURL() {
return this.baseURL;
}
// Cache management
clearCache() {
this.cache.clear();
}
clearCacheEntry(key) {
this.cache.delete(key);
}
getCacheStats() {
const cacheStats = this.cache.getStats();
const total = this.requestMetrics.cacheHits + this.requestMetrics.cacheMisses;
return {
size: cacheStats.size,
hits: this.requestMetrics.cacheHits,
misses: this.requestMetrics.cacheMisses,
hitRate: total > 0 ? this.requestMetrics.cacheHits / total : 0
};
}
getMetrics() {
return {
...this.requestMetrics
};
}
// Test-only utility
static __resetTokensForTests() {
try {
TokenStore.getInstance().clearTokens();
} catch (error) {
// Silently fail in test cleanup - this is expected behavior
// TokenStore might not be initialized in some test scenarios
}
}
}
//# sourceMappingURL=HttpService.js.map