137 lines
3.5 KiB
JavaScript
137 lines
3.5 KiB
JavaScript
/**
|
|
* 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;
|
|
})();
|
|
|
|
|