const MAX_CONTEXT_LEN = 2000; const RECENT_WINDOW_MS = 5000; const UI_ERROR_REPORTING_KEY = 'shine-ui-send-errors-to-server-v1'; let transport = null; let transportDepth = 0; const recentFingerprints = new Map(); let notifySent = null; function nowTs() { return Date.now(); } function cleanString(value, maxLen = 1000) { if (value == null) return ''; const normalized = String(value).replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, Math.max(0, maxLen - 3))}...`; } function stringifyContext(context) { if (context == null) return ''; try { const raw = JSON.stringify(context); if (!raw) return ''; if (raw.length <= MAX_CONTEXT_LEN) return raw; return `${raw.slice(0, MAX_CONTEXT_LEN - 3)}...`; } catch (error) { return cleanString(`context_json_error:${error?.message || error}`, MAX_CONTEXT_LEN); } } function makeFingerprint(payload) { return [ payload.kind, payload.message, payload.sourceUrl, payload.lineNumber, payload.columnNumber, payload.requestOp, ].join('|'); } function isDuplicate(fingerprint) { const ts = nowTs(); const prev = recentFingerprints.get(fingerprint); recentFingerprints.set(fingerprint, ts); for (const [key, time] of recentFingerprints.entries()) { if (ts - time > RECENT_WINDOW_MS) { recentFingerprints.delete(key); } } return prev != null && ts - prev < RECENT_WINDOW_MS; } function buildPayload(details = {}) { return { kind: cleanString(details.kind || 'client_error', 64), message: cleanString(details.message || details.reason || 'Unknown client error', 500), stack: cleanString(details.stack || details.error?.stack || '', 8000), sourceUrl: cleanString(details.sourceUrl || details.fileName || '', 240), lineNumber: Number.isFinite(details.lineNumber) ? details.lineNumber : null, columnNumber: Number.isFinite(details.columnNumber) ? details.columnNumber : null, route: cleanString(details.route || window.location?.hash || '', 200), href: cleanString(details.href || window.location?.href || '', 240), userAgent: cleanString(details.userAgent || navigator.userAgent || '', 240), clientTs: Number.isFinite(details.clientTs) ? details.clientTs : nowTs(), requestOp: cleanString(details.requestOp || '', 64), requestIdRef: cleanString(details.requestIdRef || '', 128), contextJson: stringifyContext({ title: document.title || '', pageVisibility: document.visibilityState || '', ...details.context, }), }; } export function setClientErrorTransport(fn) { transport = typeof fn === 'function' ? fn : null; } export function setClientErrorSentNotifier(fn) { notifySent = typeof fn === 'function' ? fn : null; } export function isClientErrorReportingEnabled() { try { return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1'; } catch { return false; } } export function setClientErrorReportingEnabled(enabled) { try { localStorage.setItem(UI_ERROR_REPORTING_KEY, enabled ? '1' : '0'); } catch { // ignore storage errors } } export async function captureClientError(details = {}) { const payload = buildPayload(details); if (!payload.message) return false; const fingerprint = details.dedupeKey || makeFingerprint(payload); if (isDuplicate(fingerprint)) return false; console.error('[client-error]', payload.kind, payload.message, details.error || ''); if (!transport || details.skipTransport === true || transportDepth > 0 || !isClientErrorReportingEnabled()) { return false; } try { transportDepth += 1; await transport(payload); if (notifySent) { try { notifySent(payload); } catch { // ignore notifier errors } } return true; } catch (error) { console.warn('client error transport failed', error); return false; } finally { transportDepth = Math.max(0, transportDepth - 1); } }