/** * Batch Progress Realtime client. * - Tries WebSocket first * - Falls back to HTTP polling on failure * - Auto heartbeats and reconnection with backoff */ (function(){ window.progress = window.progress || {}; function getAuthToken() { try { return (window.app && window.app.token) || localStorage.getItem('auth_token') || null; } catch (_) { return null; } } function buildWsUrl(path) { const loc = window.location; const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; const token = encodeURIComponent(getAuthToken() || ''); const sep = path.includes('?') ? '&' : '?'; return `${proto}//${loc.host}${path}${sep}token=${token}`; } function defaultOnUpdate(){/* no-op */} function defaultOnError(){/* no-op */} /** * Subscribe to a batch progress stream. * @param {string} batchId * @param {(data: object|null) => void} onUpdate * @param {(error: Error|string) => void} onError * @param {number} pollIntervalMs * @returns {() => void} unsubscribe function */ function subscribe(batchId, onUpdate = defaultOnUpdate, onError = defaultOnError, pollIntervalMs = 2000) { let ws = null; let closed = false; let pollTimer = null; let backoffMs = 1000; async function pollOnce() { try { const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`); if (!resp.ok) { const err = await window.http.toError(resp, 'Failed to fetch batch progress'); throw err; } const json = await resp.json(); onUpdate(json); } catch (e) { onError(e); } } function startPolling() { if (closed) return; if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(pollOnce, pollIntervalMs); // immediate first fetch pollOnce(); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } function tryWebSocket() { const url = buildWsUrl(`/api/billing/statements/batch-progress/ws/${encodeURIComponent(batchId)}`); try { ws = new WebSocket(url); } catch (e) { onError(e); startPolling(); return; } let pingTimer = null; ws.onopen = function() { stopPolling(); backoffMs = 1000; // send heartbeat pings at 30s pingTimer = setInterval(function(){ try { ws.send(JSON.stringify({type: 'ping'})); } catch (_) {} }, 30000); }; ws.onmessage = function(ev) { try { const msg = JSON.parse(ev.data); if (msg && msg.type === 'progress') { onUpdate(msg.data); } } catch (_) { // ignore } }; ws.onerror = function(ev) { onError(new Error('WebSocket error')); }; ws.onclose = function() { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } if (closed) return; // graceful fallback to polling and schedule reconnect with backoff startPolling(); setTimeout(function(){ if (!closed) { backoffMs = Math.min(backoffMs * 2, 30000); tryWebSocket(); } }, backoffMs); }; } // Kick off tryWebSocket(); return function unsubscribe() { closed = true; stopPolling(); try { if (ws && ws.readyState <= 1) ws.close(); } catch(_) {} ws = null; }; } window.progress.subscribe = subscribe; })();