UNPKG

fanos

Version:

Fanos is a lightweight JavaScript library for reliable data transmission via the Beacon API, with auto-retry, Fetch fallback, batching, and flexible triggers.

397 lines (340 loc) 10.5 kB
/** * Copyright 2025 A.S Nassiry * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. * * @see https://github.com/nassiry/fanosjs */ const config = Object.freeze({ url: null, headers: { 'Content-Type': 'application/json' }, storeKey: '__FANOS__', autoFlush: true, storeFailed: true, debug: false, retryInterval: 5000, maxPayloadSize: 64000, maxAttemptsPerRequest: 3, maxRetryCycles: 10, maxRetryDelay: 300000, fallbackToFetch: false, }); const normalizePayload = (data, headers) => { if ( data instanceof Blob || data instanceof FormData || data instanceof URLSearchParams ) { return data } return new Blob([JSON.stringify(data)], { type: headers['Content-Type'] }) }; const createPayload = (data, headers) => normalizePayload(data, headers); const getPayloadSize = (payload) => { if (payload instanceof Blob) return payload.size if (payload instanceof FormData) { return [...payload.entries()].reduce( (size, [key, value]) => size + key.length + (typeof value === 'string' ? value.length : 0), 0, ) } if (payload instanceof URLSearchParams) { return new TextEncoder().encode(payload.toString()).length } return new TextEncoder().encode(JSON.stringify(payload)).length }; function debounce(fn, delay) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => fn.apply(this, args), delay); } } const debugLog = (debug, ...args) => { if (debug === true || debug === 'info') { console.debug('[Fanos]', ...args); } }; const randomUUID = (debug) => { try { if (crypto && crypto.randomUUID) { return crypto.randomUUID() } } catch (e) { debugLog( debug, 'crypto.randomUUID() failed, falling back to alternative method', e, ); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0, v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16) }) }; class RequestHandler { constructor(config, queue) { this._config = config; this._queue = queue; } sendRequest(request) { const payload = createPayload(request.data, request.headers); const payloadSize = getPayloadSize(payload); if (payloadSize > this._config.maxPayloadSize) { debugLog(this._config.debug, 'Warning: Payload too large, splitting...'); return this.splitAndSend(request) } const success = navigator.sendBeacon(request.url, payload); if (success) { debugLog(this._config.debug, 'Beacon succeeded:', request.id); this._queue.delete(request); return true } if (this._config.fallbackToFetch) { return this.fetchFallback(request) } return false } createRequest(data, options) { return { id: randomUUID(this._config.debug), url: options.url || this._config.url, headers: { ...this._config.headers, ...options.headers }, data, attempts: 0, timestamp: Date.now(), } } async splitAndSend(request) { debugLog(this._config.debug, `Splitting large request: ${request.id}`); const payload = normalizePayload(request.data, request.headers); const payloadStr = payload instanceof Blob ? await payload.text() : JSON.stringify(request.data); const chunkSize = this._config.maxPayloadSize - 1000; const chunks = []; for (let i = 0; i < payloadStr.length; i += chunkSize) { chunks.push(payloadStr.slice(i, i + chunkSize)); } let allSent = true; chunks.forEach((chunk, index) => { const chunkBlob = new Blob([chunk], { type: request.headers['Content-Type'], }); const success = navigator.sendBeacon(request.url, chunkBlob); if (!success) { debugLog(this._config.debug, `Chunk ${index + 1} failed to send`); allSent = false; } }); if (allSent) { this._queue.delete(request); } return allSent } async fetchFallback(request) { try { const response = await fetch(request.url, { method: 'POST', headers: request.headers, body: createPayload(request.data, request.headers), keepalive: true, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`) } debugLog(this._config.debug, 'Fetch fallback succeeded:', request.id); this._queue.delete(request); return true } catch (error) { debugLog(this._config.debug, 'Fetch fallback failed:', error); if (error.name === 'TypeError') { debugLog(this._config.debug, 'Network error detected.'); } else { debugLog(this._config.debug, 'HTTP error detected.'); } return false; } } } // Fanos.js class Fanos { static defaults = config static instance = new Fanos() constructor(options = {}) { this.config = { ...Fanos.defaults, ...options }; this.queue = new Set(); this.abortController = new AbortController(); this.requestHandler = new RequestHandler(this.config, this.queue); this.retryTimer = null; this._initialized = false; } static send(data, options) { return Fanos.instance.send(data, options) } static configure(options) { Object.assign(Fanos.instance.config, options); return Fanos.instance } static flush() { Fanos.instance?.flush(); } static destroy() { Fanos.instance?.destroy(); } send(data, options = {}) { return new Promise((resolve, reject) => { if (!this._initialized) this._initialize(); const request = this.requestHandler.createRequest(data, options); debugLog(this.config.debug, 'Sending request:', { request }); try { const success = this.requestHandler.sendRequest(request); success ? resolve() : this._handleFailure(request, reject); } catch (error) { this._handleFailure(request, reject, error); } }) } flush() { if (this.queue.size === 0) { debugLog(this.config.debug, 'Queue is empty, skipping flush.'); return } debugLog(this.config.debug, `Flushing queue with ${this.queue.size} items`); const successfulRequests = new Set(); const maxRetry = this.config.maxAttemptsPerRequest; for (const request of this.queue) { if (request.attempts >= maxRetry) continue try { if (this.requestHandler.sendRequest(request)) successfulRequests.add(request.id); } catch (error) { debugLog(this.config.debug, 'Flush error:', error); } } this.queue.forEach((request) => { if (successfulRequests.has(request.id) || request.attempts >= maxRetry) { this.queue.delete(request); } }); if (this.queue.size > 0) { this._persistQueue(); } else if (this.config.debug) { debugLog(this.config.debug, 'Queue is empty, skipping persistence.'); } } destroy() { debugLog(this.config.debug, 'Destroying instance'); this.abortController.abort(); clearTimeout(this.retryTimer); this.queue.clear(); this._persistQueue(); this._initialized = false; } _initialize() { debugLog(this.config.debug, 'Initializing...'); this._loadQueue(); this._eventHandler(); this._scheduleRetries(); this._initialized = true; } _handleFailure(request, reject, error = new Error('Beacon failed')) { request.attempts++; debugLog( this.config.debug, `Request ${request.id} failed (attempt ${request.attempts})`, ); if (this.config.storeFailed) { this.queue.add(request); this._persistQueue(); } reject(error); } _scheduleRetries(attempt = 1) { if (this.retryTimer) clearTimeout(this.retryTimer); if (this.queue.size === 0) { debugLog(this.config.debug, 'Queue is empty, stopping retries.'); return } const maxRetryCycles = this.config.maxRetryCycles || 10; if (attempt > maxRetryCycles) { debugLog( this.config.debug, 'Max retry attempts reached, stopping retries.', ); return } const maxDelay = this.config.maxRetryDelay || 300000; const delay = Math.min( this.config.retryInterval * 2 ** (attempt - 1) * (1 + Math.random() * 0.2), maxDelay, ); this.retryTimer = setTimeout(() => { this.flush(); this._scheduleRetries(attempt + 1); }, delay); } _eventHandler() { if (!this.config.autoFlush) return this.abortController.abort(); this.abortController = new AbortController(); const handler = () => { if (this.queue.size > 0) { debugLog(this.config.debug, 'Auto-flushing queue due to unload event.'); this.flush(); } else if (this.config.debug) { debugLog(this.config.debug, 'Queue is empty, skipping auto-flush.'); } }; const { signal } = this.abortController ;['pagehide', 'beforeunload', 'visibilitychange'].forEach((event) => { window.addEventListener(event, handler, { signal }); }); } _loadQueue() { try { const stored = localStorage.getItem(this.config.storeKey); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { this.queue = new Set(parsed); debugLog( this.config.debug, `Loaded queue from storage: ${parsed.length} items`, ); } } } catch (error) { debugLog(this.config.debug, 'Error loading queue:', error); this.queue.clear(); localStorage.removeItem(this.config.storeKey); } } _persistQueue = debounce(() => { if (!this.config.storeFailed || this.queue.size === 0) { if (this.config.debug) { debugLog( this.config.debug, 'Skipping persistence: queue is empty or storeFailed is false.', ); } return } try { const serialized = JSON.stringify( [...Array.from(this.queue)].slice(0, 100), ); localStorage.setItem(this.config.storeKey, serialized); debugLog(this.config.debug, `Persisted queue: ${this.queue.size} items`); } catch (error) { debugLog(this.config.debug, 'Persist error:', error); } }, 500) } export { Fanos as default }; //# sourceMappingURL=fanos.esm.js.map