UNPKG

magically-sdk

Version:

Official SDK for Magically - Build mobile apps with AI

236 lines (202 loc) 7.61 kB
import { Logger } from './Logger'; import { SDKConfig } from './types'; export interface RequestOptions { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; headers?: Record<string, string>; body?: any; operation?: string; // For logging context } export class APIClient { private config: SDKConfig; private logger: Logger; private baseUrl: string; private apiKey: string | null = null; // Infinite loop detection private callHistory: Map<string, number[]> = new Map(); private readonly LOOP_THRESHOLD = 10; // Max calls per method in time window private readonly TIME_WINDOW = 5000; // 5 seconds private lastCleanup = 0; // Track last garbage collection private readonly CLEANUP_INTERVAL = 30000; // Clean up every 30 seconds constructor(config: SDKConfig, loggerPrefix: string = 'MagicallyAPI') { this.config = config; this.logger = new Logger(config.debug || false, loggerPrefix); this.baseUrl = config.apiUrl || 'https://trymagically.com'; // Use API key from config if provided (for edge functions) if (config.apiKey) { this.apiKey = config.apiKey; this.logger.info('API key provided in config - using API key authentication'); } else if (typeof globalThis !== 'undefined' && 'MAGICALLY_API_KEY' in globalThis) { // Fallback to global environment this.apiKey = (globalThis as any).MAGICALLY_API_KEY; this.logger.info('API key detected in environment - using API key authentication'); } } /** * Make an authenticated API request with automatic logging */ async request<T = any>( endpoint: string, options: RequestOptions, token?: string | null ): Promise<T> { // Check for infinite loops before making request this.checkForInfiniteLoop(options.operation || endpoint); const startTime = Date.now(); let requestId: string = ''; const url = `${this.baseUrl}${endpoint}`; const headers: Record<string, string> = { 'Content-Type': 'application/json', ...options.headers, }; // Use API key if available (edge environment), otherwise use provided token if (this.apiKey) { headers['Authorization'] = `Bearer ${this.apiKey}`; this.logger.debug('Using API key authentication for request'); } else if (token) { headers['Authorization'] = `Bearer ${token}`; } const requestConfig: RequestInit = { method: options.method, headers, body: options.body ? JSON.stringify(options.body) : undefined, }; // Log the request requestId = this.logger.networkRequest(options.method, url, { headers, body: options.body, operation: options.operation }); try { const response = await fetch(url, requestConfig); const responseData = await response.json(); const duration = Date.now() - startTime; if (!response.ok) { // Log error response this.logger.networkError(requestId, responseData, { duration, operation: options.operation }); // Throw structured error this.handleAPIError(responseData, `${options.operation || 'Request'} failed`); } // Log successful response this.logger.networkResponse(requestId, { status: response.status, statusText: response.statusText, duration, data: this.sanitizeResponseData(responseData, options.operation), operation: options.operation }); return responseData; } catch (error) { // Log network errors if (requestId) { this.logger.networkError(requestId, error, { duration: Date.now() - startTime, operation: options.operation }); } throw error; } } /** * Check if running in edge environment (has API key) */ isEdgeEnvironment(): boolean { return this.apiKey !== null; } /** * Sanitize response data for logging (avoid logging large arrays) */ private sanitizeResponseData(data: any, operation?: string): any { if (operation === 'query' && data.data && Array.isArray(data.data)) { return { ...data, dataCount: data.data.length, data: '[Array of items]' }; } return data; } /** * Check for infinite loops by tracking method call frequency */ private checkForInfiniteLoop(methodName: string): void { const now = Date.now(); const cleanMethodName = methodName.replace(/[^a-zA-Z0-9:_-]/g, '_'); // Get or create call history for this method if (!this.callHistory.has(cleanMethodName)) { this.callHistory.set(cleanMethodName, []); } const calls = this.callHistory.get(cleanMethodName)!; // Auto garbage collection - clean up stale method entries periodically if (now - this.lastCleanup > this.CLEANUP_INTERVAL) { this.garbageCollectCallHistory(now); this.lastCleanup = now; } // Remove calls outside the time window const cutoffTime = now - this.TIME_WINDOW; const recentCalls = calls.filter(timestamp => timestamp > cutoffTime); // Add current call recentCalls.push(now); // If no recent calls, remove the method entry entirely if (recentCalls.length === 1) { this.callHistory.set(cleanMethodName, recentCalls); } else { this.callHistory.set(cleanMethodName, recentCalls); } // Check if we've exceeded the threshold if (recentCalls.length > this.LOOP_THRESHOLD) { // Use logger for structured warning this.logger.error('🚨 INFINITE LOOP DETECTED', { method: methodName, callCount: recentCalls.length, timeWindow: `${this.TIME_WINDOW/1000}s`, threshold: this.LOOP_THRESHOLD, message: `Method '${methodName}' called ${recentCalls.length} times in ${this.TIME_WINDOW/1000}s` }); // Clear the history to prevent further spam this.callHistory.set(cleanMethodName, []); // Throw error to stop the loop throw new Error(`Infinite loop detected in method: ${methodName}. Too many rapid calls (${recentCalls.length} in ${this.TIME_WINDOW/1000}s)`); } } /** * Garbage collect stale call history entries to prevent memory leaks */ private garbageCollectCallHistory(now: number): void { const cutoffTime = now - this.TIME_WINDOW; const methodsToDelete: string[] = []; // Check each method's call history for (const [methodName, timestamps] of this.callHistory.entries()) { // Filter out old calls const recentCalls = timestamps.filter(timestamp => timestamp > cutoffTime); if (recentCalls.length === 0) { // No recent calls - mark for deletion methodsToDelete.push(methodName); } else { // Update with only recent calls this.callHistory.set(methodName, recentCalls); } } // Remove stale method entries methodsToDelete.forEach(methodName => { this.callHistory.delete(methodName); }); if (methodsToDelete.length > 0) { this.logger.debug('Garbage collected call history', { removedMethods: methodsToDelete.length, totalMethods: this.callHistory.size }); } } /** * Handle API errors with structured error information */ private handleAPIError(errorData: any, fallbackMessage: string): never { // Create an error with the response data attached for the Logger const error = new Error(errorData.error || errorData.message || fallbackMessage) as any; error.responseData = errorData; // Preserve the original error response for Logger throw error; } }