@oxyhq/services
Version:
Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀
524 lines (444 loc) • 17 kB
text/typescript
/**
* 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';
import type { OxyConfig } from '../models/interfaces';
interface JwtPayload {
exp?: number;
userId?: string;
id?: string;
sessionId?: string;
[key: string]: any;
}
export interface RequestOptions {
cache?: boolean;
cacheTTL?: number;
deduplicate?: boolean;
retry?: boolean;
maxRetries?: number;
timeout?: number;
signal?: AbortSignal;
}
interface RequestConfig extends RequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
data?: unknown;
params?: Record<string, unknown>;
}
/**
* Token store for authentication (singleton)
*/
class TokenStore {
private static instance: TokenStore;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private constructor() {}
static getInstance(): TokenStore {
if (!TokenStore.instance) {
TokenStore.instance = new TokenStore();
}
return TokenStore.instance;
}
setTokens(accessToken: string, refreshToken = ''): void {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
getAccessToken(): string | null {
return this.accessToken;
}
getRefreshToken(): string | null {
return this.refreshToken;
}
clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
}
hasAccessToken(): boolean {
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 {
private baseURL: string;
private tokenStore: TokenStore;
private cache: TTLCache<any>;
private deduplicator: RequestDeduplicator;
private requestQueue: RequestQueue;
private logger: SimpleLogger;
private config: OxyConfig;
// Performance monitoring
private requestMetrics = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
cacheHits: 0,
cacheMisses: 0,
averageResponseTime: 0,
};
constructor(config: OxyConfig) {
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<any>(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<T = unknown>(config: RequestConfig): Promise<T> {
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) as T | null;
if (cached !== null) {
this.requestMetrics.cacheHits++;
this.logger.debug('Cache hit:', url);
return cached;
}
this.requestMetrics.cacheMisses++;
}
// Request function
const requestFn = async (): Promise<T> => {
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: Record<string, string> = {
'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() as { message?: string } | null;
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) as Error & {
status?: number;
response?: { status: number; statusText: string }
};
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: unknown;
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 as T;
} catch (error: unknown) {
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
*/
private generateCacheKey(method: string, url: string, data?: unknown): string {
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
*/
private buildURL(url: string, params?: Record<string, unknown>): string {
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
*/
private async getAuthHeader(): Promise<string | null> {
const accessToken = this.tokenStore.getAccessToken();
if (!accessToken) {
return null;
}
try {
const decoded = jwtDecode<JwtPayload>(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
*/
private unwrapResponse(responseData: unknown): unknown {
// 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
*/
private updateMetrics(success: boolean, duration: number): void {
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<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<{ data: T }> {
const result = await this.request<T>({ method: 'GET', url, ...config });
return { data: result as T };
}
async post<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<{ data: T }> {
const result = await this.request<T>({ method: 'POST', url, data, ...config });
return { data: result as T };
}
async put<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<{ data: T }> {
const result = await this.request<T>({ method: 'PUT', url, data, ...config });
return { data: result as T };
}
async patch<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<{ data: T }> {
const result = await this.request<T>({ method: 'PATCH', url, data, ...config });
return { data: result as T };
}
async delete<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<{ data: T }> {
const result = await this.request<T>({ method: 'DELETE', url, ...config });
return { data: result as T };
}
// Token management
setTokens(accessToken: string, refreshToken = ''): void {
this.tokenStore.setTokens(accessToken, refreshToken);
}
clearTokens(): void {
this.tokenStore.clearTokens();
}
getAccessToken(): string | null {
return this.tokenStore.getAccessToken();
}
hasAccessToken(): boolean {
return this.tokenStore.hasAccessToken();
}
getBaseURL(): string {
return this.baseURL;
}
// Cache management
clearCache(): void {
this.cache.clear();
}
clearCacheEntry(key: string): void {
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(): void {
try {
TokenStore.getInstance().clearTokens();
} catch (error) {
// Silently fail in test cleanup - this is expected behavior
// TokenStore might not be initialized in some test scenarios
}
}
}