UNPKG

@profplum700/etsy-v3-api-client

Version:

JavaScript/TypeScript client for the Etsy Open API v3 with OAuth 2.0 authentication

1,392 lines (1,385 loc) 161 kB
class EtsyApiError extends Error { constructor(message, _statusCode, _response, _retryAfter, endpoint) { super(message); this._statusCode = _statusCode; this._response = _response; this._retryAfter = _retryAfter; this.name = 'EtsyApiError'; this.endpoint = endpoint; this.timestamp = new Date(); this.details = { statusCode: _statusCode || 0, retryAfter: _retryAfter, endpoint, timestamp: this.timestamp }; if (_response && typeof _response === 'object') { const resp = _response; this.details.errorCode = (resp.error_code || resp.code); this.details.field = resp.field; this.details.suggestion = (resp.suggestion || resp.message); if (Array.isArray(resp.errors)) { this.details.validationErrors = resp.errors.map((err) => { if (err && typeof err === 'object') { const e = err; return { field: String(e.field || 'unknown'), message: String(e.message || 'Validation error') }; } return { field: 'unknown', message: String(err) }; }); } } this.suggestions = this.generateSuggestions(); this.docsUrl = this.generateDocsUrl(); } get statusCode() { return this._statusCode; } get response() { return this._response; } isRetryable() { if (!this._statusCode) { return false; } if (this._statusCode === 429) { return true; } if (this._statusCode >= 500 && this._statusCode < 600) { return true; } const retryableClientErrors = [408, 409, 423, 425]; if (retryableClientErrors.includes(this._statusCode)) { return true; } return false; } getRetryAfter() { return this._retryAfter || null; } getRateLimitReset() { if (this._statusCode !== 429 || !this._retryAfter) { return null; } return new Date(Date.now() + this._retryAfter * 1000); } generateSuggestions() { const suggestions = []; if (!this._statusCode) { return ['Check your network connection and try again']; } switch (this._statusCode) { case 400: suggestions.push('Review the Etsy API documentation for this endpoint'); suggestions.push('Check all required parameters are provided'); suggestions.push('Validate parameter formats and types'); if (this.details.validationErrors && this.details.validationErrors.length > 0) { suggestions.push('\nValidation errors:'); this.details.validationErrors.forEach(err => { suggestions.push(` • ${err.field}: ${err.message}`); }); } if (this.details.field) { suggestions.push(`Field causing issue: ${this.details.field}`); } break; case 401: suggestions.push('Verify your access token is valid and not expired'); suggestions.push('Check if you need to refresh the token'); suggestions.push('Ensure you completed the OAuth flow correctly'); if (this.endpoint?.includes('/shops/')) { suggestions.push('Verify the shop_id matches the authenticated user'); } break; case 403: suggestions.push('Check if your OAuth app has the required scopes:'); if (this.endpoint?.includes('/listings')) { suggestions.push(' • listings_r (for reading listings)'); suggestions.push(' • listings_w (for creating/updating listings)'); } if (this.endpoint?.includes('/receipts') || this.endpoint?.includes('/transactions')) { suggestions.push(' • transactions_r (for reading orders)'); } if (this.endpoint?.includes('/shops')) { suggestions.push(' • shops_r (for reading shop data)'); suggestions.push(' • shops_w (for updating shop data)'); } suggestions.push('Verify your app is approved for production access'); suggestions.push('Check if the resource belongs to the authenticated user'); break; case 404: suggestions.push('Verify the resource ID exists and is spelled correctly'); if (this.endpoint?.includes('/listings/')) { suggestions.push('Check if the listing is active and not deleted'); suggestions.push('Ensure the listing belongs to the authenticated shop'); } if (this.endpoint?.includes('/shops/')) { suggestions.push('Verify the shop ID is correct'); } if (this.endpoint?.includes('/receipts/')) { suggestions.push('Check if the receipt ID is valid'); suggestions.push('Ensure you have access to this shop\'s receipts'); } break; case 409: suggestions.push('Resource state conflict detected'); suggestions.push('Check if the resource was modified by another process'); suggestions.push('Try fetching the latest resource state before updating'); break; case 429: { const resetTime = this.getRateLimitReset(); const resetTimeStr = resetTime ? resetTime.toLocaleTimeString() : 'shortly'; suggestions.push(`Rate limit exceeded. Resets at ${resetTimeStr}`); suggestions.push('Implement exponential backoff retry logic'); suggestions.push('Consider caching responses to reduce API calls'); suggestions.push('Check if you can batch multiple operations'); if (this._retryAfter) { suggestions.push(`Wait ${this._retryAfter} seconds before retrying`); } break; } case 500: case 502: case 503: case 504: suggestions.push('This is an Etsy server error, not your code'); suggestions.push('Retry the request after a short delay (exponential backoff)'); suggestions.push('Check Etsy API status: https://status.etsy.com'); if (this.isRetryable()) { suggestions.push('This error is retryable - the request can be safely retried'); } break; default: suggestions.push('Check the Etsy API documentation for this endpoint'); suggestions.push('Review your request parameters and format'); if (this.details.suggestion) { suggestions.push(`Etsy suggestion: ${this.details.suggestion}`); } } return suggestions; } generateDocsUrl() { const errorCode = this.details.errorCode?.toLowerCase() || this._statusCode?.toString() || 'unknown'; return `https://github.com/profplum700/etsy-v3-api-client/blob/main/docs/troubleshooting/ERROR_CODES.md#${errorCode}`; } getUserFriendlyMessage() { let message = this.message; if (this.details.suggestion) { message += `\nSuggestion: ${this.details.suggestion}`; } if (this.isRetryable()) { const retryAfter = this.getRetryAfter(); if (retryAfter) { message += `\nRetry after ${retryAfter} seconds.`; } else { message += '\nThis error can be retried.'; } } return message; } toString() { const parts = [ `EtsyApiError: ${this.message}`, `Status Code: ${this._statusCode || 'Unknown'}`, ]; if (this.details.errorCode) { parts.push(`Error Code: ${this.details.errorCode}`); } if (this.endpoint) { parts.push(`Endpoint: ${this.endpoint}`); } parts.push(`Timestamp: ${this.timestamp.toISOString()}`); if (this.suggestions.length > 0) { parts.push('\nSuggestions:'); this.suggestions.forEach(s => { if (!s.startsWith(' •')) { parts.push(` • ${s}`); } else { parts.push(s); } }); } parts.push(`\nDocumentation: ${this.docsUrl}`); return parts.join('\n'); } toJSON() { return { name: this.name, message: this.message, statusCode: this._statusCode, errorCode: this.details.errorCode, endpoint: this.endpoint, suggestions: this.suggestions, docsUrl: this.docsUrl, timestamp: this.timestamp.toISOString(), details: this.details, isRetryable: this.isRetryable(), }; } } class EtsyAuthError extends Error { constructor(message, _code) { super(message); this._code = _code; this.name = 'EtsyAuthError'; } get code() { return this._code; } } class EtsyRateLimitError extends Error { constructor(message, _retryAfter, errorType = 'unknown') { super(message); this._retryAfter = _retryAfter; this.name = 'EtsyRateLimitError'; this.errorType = errorType; } get retryAfter() { return this._retryAfter; } isRetryable() { return this.errorType !== 'qpd_exhausted'; } } const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined'; const isNode$1 = typeof process !== 'undefined' && !!process.versions?.node; const isWebWorker = typeof globalThis.importScripts === 'function' && typeof navigator !== 'undefined'; const hasFetch = typeof fetch !== 'undefined'; const hasWebCrypto = typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'; const hasLocalStorage = (() => { try { return typeof localStorage !== 'undefined'; } catch { return false; } })(); const hasSessionStorage = (() => { try { return typeof sessionStorage !== 'undefined'; } catch { return false; } })(); function getEnvironmentInfo() { return { isBrowser: isBrowser$1, isNode: Boolean(isNode$1), isWebWorker, hasFetch, hasWebCrypto, hasLocalStorage, hasSessionStorage, userAgent: isBrowser$1 ? navigator.userAgent : 'Node.js', nodeVersion: isNode$1 ? process.version : undefined, }; } function assertFetchSupport() { if (!hasFetch) { throw new Error('Fetch API is not available. Please use Node.js 18+ or a modern browser.'); } } function getAvailableStorage() { if (isNode$1) { return 'file'; } else if (hasLocalStorage) { return 'localStorage'; } else if (hasSessionStorage) { return 'sessionStorage'; } else { return 'memory'; } } class MemoryTokenStorage { constructor() { this.tokens = null; } async save(tokens) { this.tokens = { ...tokens }; } async load() { return this.tokens ? { ...this.tokens } : null; } async clear() { this.tokens = null; } } class TokenManager { constructor(config, storage, rotationConfig) { this.currentTokens = null; this.keystring = config.keystring; this.refreshCallback = config.refreshSave; this.storage = storage; this.rotationConfig = rotationConfig; this.currentTokens = { access_token: config.accessToken, refresh_token: config.refreshToken, expires_at: config.expiresAt, token_type: 'Bearer', scope: '' }; if (this.rotationConfig?.enabled && this.rotationConfig?.autoSchedule) { this.startRotationScheduler(); } } async getAccessToken() { if (!this.currentTokens) { if (this.storage) { this.currentTokens = await this.storage.load(); } if (!this.currentTokens) { throw new EtsyAuthError('No tokens available', 'NO_TOKENS'); } } const now = new Date(); const expiresAt = new Date(this.currentTokens.expires_at); const bufferTime = 60 * 1000; if (now.getTime() >= (expiresAt.getTime() - bufferTime)) { await this.refreshToken(); } return this.currentTokens.access_token; } async refreshToken() { if (this.refreshPromise) { return this.refreshPromise; } if (!this.currentTokens) { throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN'); } this.refreshPromise = this.performTokenRefresh(); try { const newTokens = await this.refreshPromise; this.currentTokens = newTokens; if (this.storage) { await this.storage.save(newTokens); } if (this.refreshCallback) { this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at); } return newTokens; } finally { this.refreshPromise = undefined; } } async performTokenRefresh() { if (!this.currentTokens) { throw new EtsyAuthError('No tokens available', 'NO_TOKENS'); } const body = new URLSearchParams({ grant_type: 'refresh_token', client_id: this.keystring, refresh_token: this.currentTokens.refresh_token }); try { const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: body.toString() }); if (!response.ok) { throw new EtsyAuthError(`Token refresh failed: ${response.status} ${response.statusText}`, 'TOKEN_REFRESH_FAILED'); } const tokenResponse = await response.json(); return { access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires_at: new Date(Date.now() + (tokenResponse.expires_in * 1000)), token_type: tokenResponse.token_type, scope: tokenResponse.scope }; } catch (_error) { if (_error instanceof EtsyAuthError) { throw _error; } throw new EtsyAuthError(`Token refresh failed: ${_error instanceof Error ? _error.message : 'Unknown error'}`, 'TOKEN_REFRESH_ERROR'); } } getCurrentTokens() { return this.currentTokens ? { ...this.currentTokens } : null; } updateTokens(tokens) { this.currentTokens = { ...tokens }; } isTokenExpired() { if (!this.currentTokens) { return true; } const now = new Date(); const expiresAt = new Date(this.currentTokens.expires_at); return now.getTime() >= expiresAt.getTime(); } willTokenExpireSoon(minutes = 5) { if (!this.currentTokens) { return true; } const now = new Date(); const expiresAt = new Date(this.currentTokens.expires_at); const bufferTime = minutes * 60 * 1000; return now.getTime() >= (expiresAt.getTime() - bufferTime); } async clearTokens() { this.currentTokens = null; if (this.storage) { await this.storage.clear(); } } getTimeUntilExpiration() { if (!this.currentTokens) { return null; } const now = new Date(); const expiresAt = new Date(this.currentTokens.expires_at); return expiresAt.getTime() - now.getTime(); } needsProactiveRotation() { if (!this.rotationConfig?.enabled || !this.currentTokens) { return false; } const timeUntilExpiration = this.getTimeUntilExpiration(); if (timeUntilExpiration === null) { return false; } const rotateBeforeExpiry = this.rotationConfig.rotateBeforeExpiry || 15 * 60 * 1000; return timeUntilExpiration <= rotateBeforeExpiry; } async rotateToken() { if (!this.currentTokens) { throw new EtsyAuthError('No tokens available to rotate', 'NO_TOKENS'); } const oldTokens = { ...this.currentTokens }; const newTokens = await this.refreshToken(); if (this.rotationConfig?.onRotation) { try { await Promise.resolve(this.rotationConfig.onRotation(oldTokens, newTokens)); } catch (error) { console.error('Token rotation callback failed:', error); } } return newTokens; } startRotationScheduler() { if (!this.rotationConfig?.enabled) { throw new Error('Token rotation is not enabled'); } this.stopRotationScheduler(); const checkInterval = this.rotationConfig.checkInterval || 60 * 1000; this.rotationTimer = setInterval(async () => { try { if (this.needsProactiveRotation()) { console.log('Proactively rotating token before expiration'); await this.rotateToken(); } } catch (error) { console.error('Failed to proactively rotate token:', error); } }, checkInterval); if (this.rotationTimer.unref) { this.rotationTimer.unref(); } } stopRotationScheduler() { if (this.rotationTimer) { clearInterval(this.rotationTimer); this.rotationTimer = undefined; } } updateRotationConfig(config) { const wasAutoScheduleEnabled = this.rotationConfig?.autoSchedule; this.rotationConfig = config; if (config.enabled && config.autoSchedule && !wasAutoScheduleEnabled) { this.startRotationScheduler(); } else if (!config.enabled || !config.autoSchedule) { this.stopRotationScheduler(); } } getRotationConfig() { return this.rotationConfig ? { ...this.rotationConfig } : undefined; } async fetch(url, options) { assertFetchSupport(); return fetch(url, options); } } function createDefaultTokenStorage(options) { if (isNode$1) { return new FileTokenStorage(options?.filePath || './etsy-tokens.json'); } else if (options?.preferSession && hasSessionStorage) { return new SessionStorageTokenStorage(options?.storageKey); } else if (hasLocalStorage) { return new LocalStorageTokenStorage(options?.storageKey); } else if (hasSessionStorage) { return new SessionStorageTokenStorage(options?.storageKey); } else { return new MemoryTokenStorage(); } } class LocalStorageTokenStorage { constructor(storageKey = 'etsy_tokens') { if (!hasLocalStorage) { throw new Error('localStorage is not available in this environment'); } this.storageKey = storageKey; } async save(tokens) { const data = JSON.stringify(tokens); localStorage.setItem(this.storageKey, data); } async load() { try { const data = localStorage.getItem(this.storageKey); if (!data) return null; const tokens = JSON.parse(data); if (tokens.expires_at) { tokens.expires_at = new Date(tokens.expires_at); } return tokens; } catch { return null; } } async clear() { localStorage.removeItem(this.storageKey); } } class SessionStorageTokenStorage { constructor(storageKey = 'etsy_tokens') { if (!hasSessionStorage) { throw new Error('sessionStorage is not available in this environment'); } this.storageKey = storageKey; } async save(tokens) { const data = JSON.stringify(tokens); sessionStorage.setItem(this.storageKey, data); } async load() { try { const data = sessionStorage.getItem(this.storageKey); if (!data) return null; const tokens = JSON.parse(data); if (tokens.expires_at) { tokens.expires_at = new Date(tokens.expires_at); } return tokens; } catch { return null; } } async clear() { sessionStorage.removeItem(this.storageKey); } } class FileTokenStorage { constructor(filePath) { if (!isNode$1) { throw new Error('FileTokenStorage is only available in Node.js environments'); } this.filePath = filePath; } async save(tokens) { if (!isNode$1) { throw new Error('FileTokenStorage is only available in Node.js'); } try { const data = JSON.stringify(tokens, null, 2); await this._writeFile(this.filePath, data); await this._setFilePermissions(this.filePath, 0o600); } catch { throw new Error('Failed to save tokens to file'); } } async load() { if (!isNode$1) { return null; } try { const data = await this._readFile(this.filePath); const tokens = JSON.parse(data); if (tokens.expires_at) { tokens.expires_at = new Date(tokens.expires_at); } return tokens; } catch { return null; } } async clear() { if (!isNode$1) { return; } try { await this._deleteFile(this.filePath); } catch { } } async _writeFile(filePath, data) { if (typeof process === 'undefined') return; const fs = await import('fs'); await fs.promises.writeFile(filePath, data, 'utf8'); } async _setFilePermissions(filePath, mode) { if (typeof process === 'undefined') return; try { const fs = await import('fs'); await fs.promises.chmod(filePath, mode); } catch { } } async _readFile(filePath) { if (typeof process === 'undefined') throw new Error('Not available'); const fs = await import('fs'); return await fs.promises.readFile(filePath, 'utf8'); } async _deleteFile(filePath) { if (typeof process === 'undefined') return; const fs = await import('fs'); await fs.promises.unlink(filePath); } } const ETSY_RATE_LIMITS = { MAX_REQUESTS_PER_DAY: 5000, MAX_REQUESTS_PER_SECOND: 5, MIN_REQUEST_INTERVAL: 200, }; class EtsyRateLimiter { constructor(config) { this.requestCount = 0; this.dailyReset = new Date(); this.lastRequestTime = 0; this.isHeaderBasedLimiting = false; this.currentRetryCount = 0; this.config = { maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY, maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND, minRequestInterval: ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL, maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000, jitter: 0.1, qpdWarningThreshold: 80, onApproachingLimit: undefined, ...config }; this.setNextDailyReset(); } setNextDailyReset() { const now = new Date(); this.dailyReset = new Date(now); this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1); this.dailyReset.setUTCHours(0, 0, 0, 0); } updateFromHeaders(headers) { if (!headers) { return; } const parsed = this.parseRateLimitHeaders(headers); if (parsed.limitPerSecond !== undefined) { this.headerLimitPerSecond = parsed.limitPerSecond; this.isHeaderBasedLimiting = true; } if (parsed.remainingThisSecond !== undefined) { this.headerRemainingThisSecond = parsed.remainingThisSecond; } if (parsed.limitPerDay !== undefined) { this.headerLimitPerDay = parsed.limitPerDay; this.isHeaderBasedLimiting = true; } if (parsed.remainingToday !== undefined) { this.headerRemainingToday = parsed.remainingToday; this.checkApproachingLimit(); } } parseRateLimitHeaders(headers) { const getHeader = (name) => { if (headers instanceof Headers) { return headers.get(name); } const lowerName = name.toLowerCase(); for (const key of Object.keys(headers)) { if (key.toLowerCase() === lowerName) { return headers[key] ?? null; } } return null; }; const parseNumber = (value) => { if (value === null) return undefined; const num = parseInt(value, 10); return Number.isNaN(num) ? undefined : num; }; return { limitPerSecond: parseNumber(getHeader('x-limit-per-second')), remainingThisSecond: parseNumber(getHeader('x-remaining-this-second')), limitPerDay: parseNumber(getHeader('x-limit-per-day')), remainingToday: parseNumber(getHeader('x-remaining-today')), retryAfter: parseNumber(getHeader('retry-after')) }; } checkApproachingLimit() { if (!this.config.onApproachingLimit) return; const limit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay; const remaining = this.headerRemainingToday ?? this.getRemainingRequests(); const used = limit - remaining; const percentageUsed = (used / limit) * 100; if (percentageUsed >= this.config.qpdWarningThreshold) { this.config.onApproachingLimit(remaining, limit, percentageUsed); } } async handleRateLimitResponse(headers) { const parsed = headers ? this.parseRateLimitHeaders(headers) : {}; this.updateFromHeaders(headers); if (this.headerRemainingToday === 0) { throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted'); } this.currentRetryCount++; if (this.currentRetryCount > this.config.maxRetries) { this.currentRetryCount = 0; throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted'); } const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter); return { shouldRetry: true, delayMs }; } calculateBackoffDelay(attempt, retryAfterSeconds) { const serverSuggestedMs = retryAfterSeconds ? retryAfterSeconds * 1000 : 0; const exponentialDelay = this.config.baseDelayMs * Math.pow(2, attempt - 1); let delay = Math.min(exponentialDelay, this.config.maxDelayMs); delay = Math.max(delay, serverSuggestedMs); if (this.config.jitter > 0) { const jitterAmount = delay * this.config.jitter; const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount; delay += randomJitter; } return Math.max(0, Math.floor(delay)); } resetRetryCount() { this.currentRetryCount = 0; } setApproachingLimitCallback(callback) { this.config.onApproachingLimit = callback; } setWarningThreshold(threshold) { this.config.qpdWarningThreshold = threshold; } getEffectiveMinInterval() { if (this.headerLimitPerSecond !== undefined && this.headerLimitPerSecond > 0) { return Math.ceil(1000 / this.headerLimitPerSecond); } return this.config.minRequestInterval; } async waitForRateLimit() { const now = Date.now(); if (now >= this.dailyReset.getTime()) { this.requestCount = 0; this.setNextDailyReset(); } const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay; const effectiveRemaining = this.headerRemainingToday ?? (effectiveDailyLimit - this.requestCount); if (effectiveRemaining <= 0) { const timeUntilReset = this.dailyReset.getTime() - now; throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` + `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted'); } const minInterval = this.getEffectiveMinInterval(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < minInterval) { const waitTime = minInterval - timeSinceLastRequest; await new Promise(resolve => setTimeout(resolve, waitTime)); } this.requestCount++; this.lastRequestTime = Date.now(); } getRateLimitStatus() { const now = Date.now(); if (now >= this.dailyReset.getTime()) { this.requestCount = 0; this.setNextDailyReset(); } const effectiveRemaining = this.headerRemainingToday ?? Math.max(0, this.config.maxRequestsPerDay - this.requestCount); return { remainingRequests: effectiveRemaining, resetTime: this.dailyReset, canMakeRequest: effectiveRemaining > 0 && (now - this.lastRequestTime) >= this.getEffectiveMinInterval(), isFromHeaders: this.isHeaderBasedLimiting, limitPerSecond: this.headerLimitPerSecond, remainingThisSecond: this.headerRemainingThisSecond, limitPerDay: this.headerLimitPerDay }; } getRemainingRequests() { return this.headerRemainingToday ?? Math.max(0, this.config.maxRequestsPerDay - this.requestCount); } reset() { this.requestCount = 0; this.lastRequestTime = 0; this.currentRetryCount = 0; this.headerLimitPerSecond = undefined; this.headerRemainingThisSecond = undefined; this.headerLimitPerDay = undefined; this.headerRemainingToday = undefined; this.isHeaderBasedLimiting = false; this.setNextDailyReset(); } canMakeRequest() { const now = Date.now(); if (now >= this.dailyReset.getTime()) { this.requestCount = 0; this.setNextDailyReset(); } const effectiveRemaining = this.headerRemainingToday ?? (this.config.maxRequestsPerDay - this.requestCount); if (effectiveRemaining <= 0) { return false; } return (now - this.lastRequestTime) >= this.getEffectiveMinInterval(); } getTimeUntilNextRequest() { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; const minInterval = this.getEffectiveMinInterval(); if (timeSinceLastRequest >= minInterval) { return 0; } return minInterval - timeSinceLastRequest; } getConfig() { return { ...this.config }; } } const defaultRateLimiter = new EtsyRateLimiter(); class BulkOperationManager { constructor(config = {}) { this.concurrency = config.concurrency ?? 5; this.stopOnError = config.stopOnError ?? false; this.onProgress = config.onProgress; this.onItemComplete = config.onItemComplete; this.onItemError = config.onItemError; } async executeBulk(items, operation, getId) { const startTime = Date.now(); const results = []; const errors = []; let completed = 0; let successful = 0; let failed = 0; const queue = items.map((item, index) => ({ item, index, id: getId ? getId(item, index) : index })); const workers = []; for (let i = 0; i < Math.min(this.concurrency, items.length); i++) { workers.push(this.worker(queue, operation, results, errors, () => { completed++; const lastResult = results[results.length - 1]; if (lastResult?.success) { successful++; } else { failed++; } if (this.onProgress) { this.onProgress(completed, items.length, lastResult); } })); } await Promise.all(workers); const duration = Date.now() - startTime; return { total: items.length, successful, failed, results, errors, duration }; } async worker(queue, operation, results, errors, onComplete) { while (queue.length > 0) { const work = queue.shift(); if (!work) break; const { item, index, id } = work; try { const data = await operation(item, index); const result = { success: true, data, id, index }; results.push(result); if (this.onItemComplete) { this.onItemComplete(result); } onComplete(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); const result = { success: false, error: err, id, index }; results.push(result); const bulkError = { id, index, error: err }; errors.push(bulkError); if (this.onItemError) { this.onItemError(bulkError); } onComplete(); if (this.stopOnError) { queue.length = 0; break; } } } } setConcurrency(concurrency) { this.concurrency = Math.max(1, concurrency); } getConcurrency() { return this.concurrency; } } function createBulkOperationManager(config) { return new BulkOperationManager(config); } async function executeBulkOperation(items, operation, config) { const manager = new BulkOperationManager(config); return manager.executeBulk(items, operation); } class Validator { constructor() { this.validators = []; } rule(validator) { this.validators.push(validator); return this; } validate(data) { const errors = []; for (const validator of this.validators) { const error = validator(data); if (error) { errors.push(error); } } return { valid: errors.length === 0, errors }; } } class FieldValidator { constructor(field) { this.field = field; } required(message) { return (data) => { if (typeof data !== 'object' || data === null) return null; const value = data[this.field]; if (value === undefined || value === null || value === '') { return { field: this.field, message: message || `${this.field} is required`, value }; } return null; }; } string(options) { return (data) => { if (typeof data !== 'object' || data === null) return null; const value = data[this.field]; if (value === undefined || value === null) return null; if (typeof value !== 'string') { return { field: this.field, message: options.message || `${this.field} must be a string`, value }; } if (options.min !== undefined && value.length < options.min) { return { field: this.field, message: options.message || `${this.field} must be at least ${options.min} characters`, value }; } if (options.max !== undefined && value.length > options.max) { return { field: this.field, message: options.message || `${this.field} must be at most ${options.max} characters`, value }; } if (options.pattern && !options.pattern.test(value)) { return { field: this.field, message: options.message || `${this.field} has invalid format`, value }; } return null; }; } number(options) { return (data) => { if (typeof data !== 'object' || data === null) return null; const value = data[this.field]; if (value === undefined || value === null) return null; if (typeof value !== 'number' || Number.isNaN(value)) { return { field: this.field, message: options.message || `${this.field} must be a number`, value }; } if (options.integer && !Number.isInteger(value)) { return { field: this.field, message: options.message || `${this.field} must be an integer`, value }; } if (options.positive && value <= 0) { return { field: this.field, message: options.message || `${this.field} must be positive`, value }; } if (options.min !== undefined && value < options.min) { return { field: this.field, message: options.message || `${this.field} must be at least ${options.min}`, value }; } if (options.max !== undefined && value > options.max) { return { field: this.field, message: options.message || `${this.field} must be at most ${options.max}`, value }; } return null; }; } enum(allowedValues, message) { return (data) => { if (typeof data !== 'object' || data === null) return null; const value = data[this.field]; if (value === undefined || value === null) return null; if (!allowedValues.includes(value)) { return { field: this.field, message: message || `${this.field} must be one of: ${allowedValues.join(', ')}`, value }; } return null; }; } array(options) { return (data) => { if (typeof data !== 'object' || data === null) return null; const value = data[this.field]; if (value === undefined || value === null) return null; if (!Array.isArray(value)) { return { field: this.field, message: options.message || `${this.field} must be an array`, value }; } if (options.min !== undefined && value.length < options.min) { return { field: this.field, message: options.message || `${this.field} must have at least ${options.min} items`, value }; } if (options.max !== undefined && value.length > options.max) { return { field: this.field, message: options.message || `${this.field} must have at most ${options.max} items`, value }; } if (options.itemValidator) { for (let i = 0; i < value.length; i++) { if (!options.itemValidator(value[i])) { return { field: this.field, message: options.message || `${this.field}[${i}] has invalid value`, value: value[i] }; } } } return null; }; } } function field(fieldName) { return new FieldValidator(fieldName); } const CreateListingSchema = new Validator() .rule(field('quantity').required()) .rule(field('quantity').number({ min: 1, integer: true, message: 'quantity must be a positive integer' })) .rule(field('title').required()) .rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' })) .rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' })) .rule(field('price').required()) .rule(field('price').number({ min: 0.2, max: 50000, message: 'price must be between 0.20 and 50000.00' })) .rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective')) .rule(field('when_made').enum([ 'made_to_order', '2020_2024', '2010_2019', '2005_2009', '2000_2004', '1990s', '1980s', '1970s', '1960s', '1950s', '1940s', '1930s', '1920s', '1910s', '1900s', '1800s', '1700s', 'before_1700' ])) .rule(field('taxonomy_id').required()) .rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' })); const UpdateListingSchema = new Validator() .rule((data) => { if (typeof data === 'object' && data !== null && 'title' in data && data.title !== undefined) { return field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' })(data); } return null; }) .rule((data) => { if (typeof data === 'object' && data !== null && 'description' in data && data.description !== undefined) { return field('description').string({ max: 65535, message: 'description must be less than 65535 characters' })(data); } return null; }) .rule((data) => { if (data.tags !== undefined) { return field('tags').array({ max: 13, message: 'tags must have at most 13 items' })(data); } return null; }) .rule((data) => { if (data.materials !== undefined) { return field('materials').array({ max: 13, message: 'materials must have at most 13 items' })(data); } return null; }); const UpdateShopSchema = new Validator() .rule((data) => { if (typeof data === 'object' && data !== null && 'title' in data && data.title !== undefined) { return field('title').string({ min: 1, max: 55, message: 'shop title must be 1-55 characters' })(data); } return null; }) .rule((data) => { if (typeof data === 'object' && data !== null && 'announcement' in data && data.announcement !== undefined) { return field('announcement').string({ max: 5000, message: 'announcement must be less than 5000 characters' })(data); } return null; }); class ValidationException extends Error { constructor(message, errors) { super(message); this.name = 'ValidationException'; this.errors = errors; } } function validateOrThrow(data, schema, errorMessage = 'Validation failed') { const result = schema.validate(data); if (!result.valid) { throw new ValidationException(errorMessage, result.errors); } } function validate(data, schema) { return schema.validate(data); } function createValidator() { return new Validator(); } function combineValidators(...validators) { return { validate(data) { const allErrors = []; for (const validator of validators) { const result = validator.validate(data); if (!result.valid) { allErrors.push(...result.errors); } } return { valid: allErrors.length === 0, errors: allErrors }; } }; } class DefaultLogger { debug(message, ...args) { let isDevelopment; if (isNode$1) { isDevelopment = process.env.NODE_ENV === 'development'; } else { const host = window.location?.hostname; isDevelopment = host === 'localhost' || host === '127.0.0.1'; } if (isDevelopment) { console.log(`[DEBUG] ${message}`, ...args); } } info(message, ...args) { console.log(`[INFO] ${message}`, ...args); } warn(message, ...args) { console.warn(`[WARN] ${message}`, ...args); } error(message, ...args) { console.error(`[ERROR] ${message}`, ...args); } } class MemoryCache { constructor() { this.cache = new Map(); } async get(key) { const entry = this.cache.get(key); if (!entry) return null; if (Date.now() > entry.expires) { this.cache.delete(key); return null; } return entry.value; } async set(key, value, ttl = 3600) { const expires = Date.now() + (ttl * 1000); this.cache.set(key, { value, expires }); } async delete(key) { this.cache.delete(key); } async clear() { this.cache.clear(); } } class EtsyClient { constructor(config) { if (!config.sharedSecret) { throw new EtsyAuthError('sharedSecret is REQUIRED for Etsy API v3 application usage. See: https://github.com/profplum700/etsy-v3-api-client/issues/21'); } this.tokenManager = new TokenManager(config); this.baseUrl = config.baseUrl || 'https://api.etsy.com/v3/application'; this.logger = new DefaultLogger(); this.keystring = config.keystring; this.sharedSecret = config.sharedSecret; if (config.rateLimiting?.enabled !== false) { this.rateLimiter = new EtsyRateLimiter({ maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY, maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND, minRequestInterval: config.rateLimiting?.minRequestInterval ?? ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL, maxRetries: config.rateLimiting?.maxRetries, baseDelayMs: config.rateLimiting?.baseDelayMs, maxDelayMs: config.rateLimiting?.maxDelayMs, jitter: config.rateLimiting?.jitter, qpdWarningThreshold: config.rateLimiting?.qpdWarningThreshold, onApproachingLimit: config.rateLimiting?.onApproachingLimit }); } else { this.rateLimiter = new EtsyRateLimiter(undefined); } if (config.caching?.enabled !== false) { this.cache = config.caching?.storage || new MemoryCache(); this.cacheTtl = config.caching?.ttl || 3600; } this.bulkOperationManager = new BulkOperationManager({ concurrency: 5, stopOnError: false }); } async makeRequest(endpoint, options = {}, useCache = true) { const url = `${this.baseUrl}${endpoint}`; const requestOptions = { method: 'GET', ...options }; const cacheKey = `${url}:${JSON.stringify(requestOptions)}`; if (useCache && this.cache && requestOptions.method === 'GET') { const cached = await this.cache.get(cacheKey); if (cached) { try { return JSON.parse(cached); } catch {