@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
JavaScript
;
/**
* 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; } });