UNPKG

@bugspotter/sdk

Version:

Professional bug reporting SDK with screenshots, session replay, and automatic error capture for web applications

353 lines (352 loc) 13.9 kB
"use strict"; /** * Transport layer for bug report submission with flexible authentication, * exponential backoff retry, and offline queue support */ Object.defineProperty(exports, "__esModule", { value: true }); exports.clearOfflineQueue = exports.TokenRefreshError = exports.TransportError = void 0; exports.getAuthHeaders = getAuthHeaders; exports.submitWithAuth = submitWithAuth; const logger_1 = require("../utils/logger"); const offline_queue_1 = require("./offline-queue"); // ============================================================================ // CONSTANTS // ============================================================================ const TOKEN_REFRESH_STATUS = 401; const JITTER_PERCENTAGE = 0.1; const DEFAULT_ENABLE_RETRY = true; // ============================================================================ // CUSTOM ERROR TYPES // ============================================================================ class TransportError extends Error { constructor(message, endpoint, cause) { super(message); this.endpoint = endpoint; this.cause = cause; this.name = 'TransportError'; } } exports.TransportError = TransportError; class TokenRefreshError extends TransportError { constructor(endpoint, cause) { super('Failed to refresh authentication token', endpoint, cause); this.name = 'TokenRefreshError'; } } exports.TokenRefreshError = TokenRefreshError; // Default configurations const DEFAULT_RETRY_CONFIG = { maxRetries: 3, baseDelay: 1000, maxDelay: 30000, retryOn: [502, 503, 504, 429], }; const DEFAULT_OFFLINE_CONFIG = { enabled: false, maxQueueSize: 10, }; const authStrategies = { 'api-key': (config) => { const apiKey = config.apiKey; return apiKey ? { 'X-API-Key': apiKey } : {}; }, jwt: (config) => { const token = config.token; return token ? { Authorization: `Bearer ${token}` } : {}; }, bearer: (config) => { const token = config.token; return token ? { Authorization: `Bearer ${token}` } : {}; }, custom: (config) => { const customHeader = config.customHeader; if (!customHeader) { return {}; } const { name, value } = customHeader; return name && value ? { [name]: value } : {}; }, none: () => { return {}; }, }; // ============================================================================ // RETRY HANDLER - Exponential Backoff Logic // ============================================================================ class RetryHandler { constructor(config, logger) { this.config = config; this.logger = logger; } /** * Execute operation with exponential backoff retry */ async executeWithRetry(operation, shouldRetryStatus) { let lastError = null; for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) { try { const response = await operation(); // Check if we should retry based on status code if (shouldRetryStatus(response.status) && attempt < this.config.maxRetries) { const delay = this.calculateDelay(attempt, response); this.logger.warn(`Request failed with status ${response.status}, retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.maxRetries})`); await sleep(delay); continue; } // Success or non-retryable status return response; } catch (error) { lastError = error; // Retry on network errors if (attempt < this.config.maxRetries) { const delay = this.calculateDelay(attempt); this.logger.warn(`Network error, retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.maxRetries}):`, error); await sleep(delay); continue; } } } // All retries exhausted throw lastError || new Error('Request failed after all retry attempts'); } /** * Calculate retry delay with exponential backoff and jitter */ calculateDelay(attempt, response) { var _a, _b; // Check for Retry-After header if ((_b = (_a = response === null || response === void 0 ? void 0 : response.headers) === null || _a === void 0 ? void 0 : _a.has) === null || _b === void 0 ? void 0 : _b.call(_a, 'Retry-After')) { const retryAfter = response.headers.get('Retry-After'); const retryAfterSeconds = parseInt(retryAfter, 10); if (!isNaN(retryAfterSeconds)) { return Math.min(retryAfterSeconds * 1000, this.config.maxDelay); } } // Exponential backoff: baseDelay * 2^attempt const exponentialDelay = this.config.baseDelay * Math.pow(2, attempt); // Add jitter: ±10% randomization const jitter = exponentialDelay * JITTER_PERCENTAGE * (Math.random() * 2 - 1); const delayWithJitter = exponentialDelay + jitter; // Cap at maxDelay return Math.min(delayWithJitter, this.config.maxDelay); } } /** * Type guard to check if parameter is TransportOptions * * Strategy: Check for properties that ONLY exist in TransportOptions, not in AuthConfig. * AuthConfig has: type, apiKey?, token?, onTokenExpired?, customHeader? * TransportOptions has: auth?, logger?, enableRetry?, retry?, offline? * * Key distinction: AuthConfig always has 'type' property, TransportOptions never does. */ function isTransportOptions(obj) { if (typeof obj !== 'object' || obj === null) { return false; } const record = obj; // If it has 'type' property, it's an AuthConfig, not TransportOptions if ('type' in record) { return false; } // Key insight: If object has TransportOptions-specific keys (even if undefined), // it's likely TransportOptions since AuthConfig never has these keys const hasTransportOptionsKeys = 'auth' in record || 'retry' in record || 'offline' in record || 'logger' in record || 'enableRetry' in record; // Return true if it has any TransportOptions-specific keys return hasTransportOptionsKeys; } /** * Parse transport parameters, supporting both legacy and new API signatures */ function parseTransportParams(authOrOptions) { var _a; if (isTransportOptions(authOrOptions)) { // Type guard ensures authOrOptions is TransportOptions return { auth: authOrOptions.auth, logger: authOrOptions.logger || (0, logger_1.getLogger)(), enableRetry: (_a = authOrOptions.enableRetry) !== null && _a !== void 0 ? _a : DEFAULT_ENABLE_RETRY, retryConfig: Object.assign(Object.assign({}, DEFAULT_RETRY_CONFIG), authOrOptions.retry), offlineConfig: Object.assign(Object.assign({}, DEFAULT_OFFLINE_CONFIG), authOrOptions.offline), }; } return { auth: authOrOptions, logger: (0, logger_1.getLogger)(), enableRetry: DEFAULT_ENABLE_RETRY, retryConfig: DEFAULT_RETRY_CONFIG, offlineConfig: DEFAULT_OFFLINE_CONFIG, }; } // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /** * Process offline queue in background */ async function processQueueInBackground(offlineConfig, retryConfig, logger) { if (!offlineConfig.enabled) { return; } const queue = new offline_queue_1.OfflineQueue(offlineConfig, logger); queue.process(retryConfig.retryOn).catch((error) => { logger.warn('Failed to process offline queue:', error); }); } /** * Handle offline failure by queueing request */ async function handleOfflineFailure(error, endpoint, body, contentHeaders, auth, offlineConfig, logger) { if (!offlineConfig.enabled || !isNetworkError(error)) { return; } logger.warn('Network error detected, queueing request for offline retry'); const queue = new offline_queue_1.OfflineQueue(offlineConfig, logger); const authHeaders = getAuthHeaders(auth); await queue.enqueue(endpoint, body, Object.assign(Object.assign({}, contentHeaders), authHeaders)); } // ============================================================================ // PUBLIC API // ============================================================================ /** * Get authentication headers based on configuration * @param auth - Authentication configuration * @returns HTTP headers for authentication */ function getAuthHeaders(auth) { // No auth if (!auth) { return {}; } // Apply strategy const strategy = authStrategies[auth.type]; return strategy ? strategy(auth) : {}; } /** * Submit request with authentication, exponential backoff retry, and offline queue support * * @param endpoint - API endpoint URL * @param body - Request body (must be serializable for retry) * @param contentHeaders - Content-related headers (Content-Type, etc.) * @param authOrOptions - Auth config or TransportOptions * @returns Response from the server */ async function submitWithAuth(endpoint, body, contentHeaders, authOrOptions) { // Parse options (support both old signature and new options-based API) const { auth, logger, enableRetry, retryConfig, offlineConfig } = parseTransportParams(authOrOptions); // Process offline queue on each request (run in background without awaiting) processQueueInBackground(offlineConfig, retryConfig, logger); try { // Send with retry logic const response = await sendWithRetry(endpoint, body, contentHeaders, auth, retryConfig, logger, enableRetry); return response; } catch (error) { // Queue for offline retry if enabled await handleOfflineFailure(error, endpoint, body, contentHeaders, auth, offlineConfig, logger); throw error; } } /** * Check if auth config supports token refresh */ function shouldRetryWithRefresh(auth) { return (typeof auth === 'object' && (auth.type === 'jwt' || auth.type === 'bearer') && typeof auth.onTokenExpired === 'function'); } /** * Make HTTP request with auth headers */ async function makeRequest(endpoint, body, contentHeaders, auth) { const authHeaders = getAuthHeaders(auth); const headers = Object.assign(Object.assign({}, contentHeaders), authHeaders); return fetch(endpoint, { method: 'POST', headers, body, }); } /** * Send request with exponential backoff retry */ async function sendWithRetry(endpoint, body, contentHeaders, auth, retryConfig, logger, enableTokenRetry) { const retryHandler = new RetryHandler(retryConfig, logger); let hasAttemptedRefresh = false; // Use retry handler with token refresh support return retryHandler.executeWithRetry(async () => { const response = await makeRequest(endpoint, body, contentHeaders, auth); // Check for 401 and retry with token refresh if applicable (only once) if (response.status === TOKEN_REFRESH_STATUS && enableTokenRetry && !hasAttemptedRefresh && shouldRetryWithRefresh(auth)) { hasAttemptedRefresh = true; const refreshedResponse = await retryWithTokenRefresh(endpoint, body, contentHeaders, auth, logger); return refreshedResponse; } return response; }, (status) => { return retryConfig.retryOn.includes(status); }); } /** * Sleep for specified milliseconds */ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Check if error is a network error (more specific to avoid false positives) */ function isNetworkError(error) { if (!(error instanceof Error)) { return false; } const message = error.message.toLowerCase(); // Check for specific network error patterns return ( // Standard fetch network errors message.includes('failed to fetch') || message.includes('network request failed') || message.includes('networkerror') || // Connection issues message.includes('network error') || message.includes('connection') || // Timeout errors message.includes('timeout') || // Standard error names error.name === 'NetworkError' || error.name === 'AbortError' || // TypeError only if it mentions fetch or network (error.name === 'TypeError' && (message.includes('fetch') || message.includes('network')))); } /** * Retry request with refreshed token */ async function retryWithTokenRefresh(endpoint, body, contentHeaders, auth, logger) { try { logger.warn('Token expired, attempting refresh...'); // Get new token const newToken = await auth.onTokenExpired(); // Create updated auth config const refreshedAuth = Object.assign(Object.assign({}, auth), { token: newToken }); // Retry request const response = await makeRequest(endpoint, body, contentHeaders, refreshedAuth); logger.log('Request retried with refreshed token'); return response; } catch (error) { logger.error('Token refresh failed:', error); // Return original 401 - caller should handle return new Response(null, { status: TOKEN_REFRESH_STATUS, statusText: 'Unauthorized' }); } } // Re-export offline queue utilities var offline_queue_2 = require("./offline-queue"); Object.defineProperty(exports, "clearOfflineQueue", { enumerable: true, get: function () { return offline_queue_2.clearOfflineQueue; } });