|
| 1 | +// Removed react-native-exception-handler; implementing lightweight JS/global handlers manually. |
| 2 | +// Native test exception helper |
| 3 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 4 | +// @ts-ignore |
| 5 | +// Removed rn-test-exception-handler (deprecated / incompatible). We'll simulate native error instead. |
| 6 | +// import RnTestExceptionHandler from 'rn-test-exception-handler'; |
| 7 | +import { notifyError } from '@/services/notifier.service'; |
| 8 | +import { showFatalErrorNotification } from '@/utils/notifications.utils'; |
| 9 | +import { Alert, Platform, NativeModules } from 'react-native'; |
| 10 | + |
| 11 | +// ============================================================================ |
| 12 | +// Error Reporting (merged from former errorReporting.service.ts) |
| 13 | +// ---------------------------------------------------------------------------- |
| 14 | +export interface ReportOptions { |
| 15 | + /** Will be set by the global handlers for native vs JS */ |
| 16 | + isFatal?: boolean; |
| 17 | + /** Logical source (hook / component / boundary / task) */ |
| 18 | + componentStack?: string; |
| 19 | + /** Arbitrary labels for filtering (small strings only) */ |
| 20 | + tags?: Record<string, string>; |
| 21 | + /** Extra structured diagnostic data */ |
| 22 | + extra?: Record<string, unknown>; |
| 23 | +} |
| 24 | + |
| 25 | +export interface ErrorReport { |
| 26 | + id: string; // client generated id for tracing |
| 27 | + name: string; |
| 28 | + message: string; |
| 29 | + stack?: string; |
| 30 | + cause?: string; |
| 31 | + timestamp: string; |
| 32 | + platform: string; |
| 33 | + isFatal?: boolean; |
| 34 | + componentStack?: string; |
| 35 | + tags?: Record<string, string>; |
| 36 | + extra?: Record<string, unknown>; |
| 37 | +} |
| 38 | + |
| 39 | +const ERROR_REPORTING_ENDPOINT = 'https://api.codebuilder.org/errors'; |
| 40 | + |
| 41 | +// Lightweight circuit breaker to avoid spamming backend on outages. |
| 42 | +const circuitBreaker = { |
| 43 | + failureCount: 0, |
| 44 | + lastFailureTime: 0, |
| 45 | + isOpen: false, |
| 46 | + failureThreshold: 3, |
| 47 | + resetTimeout: 60_000, // 1 min |
| 48 | + baseDelay: 5_000, // 5s |
| 49 | + maxDelay: 3_600_000, // 1h |
| 50 | + shouldPreventApiCall(): boolean { |
| 51 | + if (!this.isOpen) return false; |
| 52 | + const now = Date.now(); |
| 53 | + const elapsed = now - this.lastFailureTime; |
| 54 | + const backoff = Math.min(this.baseDelay * Math.pow(2, this.failureCount - 1), this.maxDelay); |
| 55 | + return elapsed <= backoff; |
| 56 | + }, |
| 57 | + recordSuccess(): void { |
| 58 | + if (this.failureCount > 0) console.log('Error reporting API recovered'); |
| 59 | + this.failureCount = 0; |
| 60 | + this.isOpen = false; |
| 61 | + }, |
| 62 | + recordFailure(): void { |
| 63 | + this.failureCount++; |
| 64 | + this.lastFailureTime = Date.now(); |
| 65 | + if (this.failureCount >= this.failureThreshold) { |
| 66 | + this.isOpen = true; |
| 67 | + const backoff = Math.min(this.baseDelay * Math.pow(2, this.failureCount - 1), this.maxDelay); |
| 68 | + console.log(`Circuit opened after ${this.failureCount} failures. Retrying in ~${Math.round(backoff / 1000)}s`); |
| 69 | + } |
| 70 | + }, |
| 71 | +}; |
| 72 | + |
| 73 | +let isReportingError = false; |
| 74 | +let nestedReportCount = 0; |
| 75 | +const MAX_NESTED_REPORTS = 3; |
| 76 | + |
| 77 | +function buildReport(error: Error, opts?: ReportOptions): ErrorReport { |
| 78 | + return { |
| 79 | + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, |
| 80 | + name: error.name, |
| 81 | + message: error.message || String(error), |
| 82 | + stack: error.stack || undefined, |
| 83 | + cause: (error as any).cause ? String((error as any).cause) : undefined, |
| 84 | + timestamp: new Date().toISOString(), |
| 85 | + platform: Platform.OS, |
| 86 | + isFatal: opts?.isFatal, |
| 87 | + componentStack: opts?.componentStack, |
| 88 | + tags: opts?.tags, |
| 89 | + extra: opts?.extra, |
| 90 | + }; |
| 91 | +} |
| 92 | + |
| 93 | +// Safe JSON stringify preventing circular reference explosions |
| 94 | +function safeStringify(value: any, depth = 0, seen = new WeakSet()): string { |
| 95 | + try { |
| 96 | + if (value === null || typeof value !== 'object') return JSON.stringify(value); |
| 97 | + if (seen.has(value)) return '"[Circular]"'; |
| 98 | + if (depth > 5) return '"[Truncated]"'; |
| 99 | + seen.add(value); |
| 100 | + if (Array.isArray(value)) { |
| 101 | + return '[' + value.slice(0, 50).map((v: any) => safeStringify(v, depth + 1, seen)).join(',') + (value.length > 50 ? ',"[+more]"' : '') + ']'; |
| 102 | + } |
| 103 | + const keys = Object.keys(value).slice(0, 50); |
| 104 | + const body = keys.map((k: string) => JSON.stringify(k) + ':' + safeStringify((value as any)[k], depth + 1, seen)).join(','); |
| 105 | + return '{' + body + (Object.keys(value).length > 50 ? ',"[+more]":"truncated"' : '') + '}'; |
| 106 | + } catch { |
| 107 | + try { return JSON.stringify(String(value)); } catch { return '"[Unserializable]"'; } |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +async function sendReportInternal(report: ErrorReport) { |
| 112 | + if (isReportingError) { |
| 113 | + nestedReportCount++; |
| 114 | + if (nestedReportCount >= MAX_NESTED_REPORTS) { |
| 115 | + console.log('Nested error reporting limit reached – aborting'); |
| 116 | + return; |
| 117 | + } |
| 118 | + return; // ignore additional nested errors |
| 119 | + } |
| 120 | + |
| 121 | + if (circuitBreaker.shouldPreventApiCall()) { |
| 122 | + console.log('Error reporting circuit open – skipping'); |
| 123 | + return; |
| 124 | + } |
| 125 | + |
| 126 | + console.log('[error-report] sending', { |
| 127 | + id: report.id, |
| 128 | + name: report.name, |
| 129 | + isFatal: report.isFatal, |
| 130 | + componentStack: report.componentStack, |
| 131 | + tags: report.tags, |
| 132 | + }); |
| 133 | + |
| 134 | + try { |
| 135 | + isReportingError = true; |
| 136 | + const resp = await fetch(ERROR_REPORTING_ENDPOINT, { |
| 137 | + method: 'POST', |
| 138 | + headers: { 'Content-Type': 'application/json' }, |
| 139 | + body: JSON.stringify(report), |
| 140 | + }); |
| 141 | + if (!resp.ok) { |
| 142 | + console.error('[error-report] failed', report.id, resp.status, resp.statusText); |
| 143 | + circuitBreaker.recordFailure(); |
| 144 | + } else { |
| 145 | + console.log('[error-report] success', report.id); |
| 146 | + circuitBreaker.recordSuccess(); |
| 147 | + } |
| 148 | + } catch (e) { |
| 149 | + console.error('[error-report] exception while sending', report.id, e); |
| 150 | + circuitBreaker.recordFailure(); |
| 151 | + } finally { |
| 152 | + isReportingError = false; |
| 153 | + nestedReportCount = 0; |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +export function reportError(maybeError: unknown, options?: ReportOptions): void { |
| 158 | + let error: Error; |
| 159 | + if (maybeError instanceof Error) { |
| 160 | + error = maybeError; |
| 161 | + } else if (typeof maybeError === 'string') { |
| 162 | + error = new Error(maybeError); |
| 163 | + } else { |
| 164 | + let serialized = 'Unknown non-Error thrown'; |
| 165 | + try { serialized = safeStringify(maybeError); } catch {} |
| 166 | + error = new Error(serialized); |
| 167 | + } |
| 168 | + |
| 169 | + try { |
| 170 | + const report = buildReport(error, options); |
| 171 | + void sendReportInternal(report); |
| 172 | + } catch (internalErr) { |
| 173 | + // Final fallback to avoid cascading crashes |
| 174 | + try { console.error('[error-report] internal failure', internalErr); } catch {} |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +export interface GlobalErrorHandlerOptions { |
| 179 | + useSystemAlert?: boolean; |
| 180 | + verbose?: boolean; |
| 181 | + baseContext?: Partial<ReportOptions>; // merged into every report |
| 182 | +} |
| 183 | + |
| 184 | +let configured = false; |
| 185 | + |
| 186 | +export function setupGlobalErrorHandlers(opts: GlobalErrorHandlerOptions = {}) { |
| 187 | + if (configured) return; |
| 188 | + configured = true; |
| 189 | + const { useSystemAlert = false, verbose = true, baseContext = {} } = opts; |
| 190 | + |
| 191 | + // JS global handler (ErrorUtils is provided by RN runtime) |
| 192 | + const originalHandler: any = (global as any).ErrorUtils?.getGlobalHandler?.(); |
| 193 | + (global as any).ErrorUtils?.setGlobalHandler?.((maybeError: any, isFatal?: boolean) => { |
| 194 | + const error = maybeError instanceof Error ? maybeError : new Error(typeof maybeError === 'string' ? maybeError : JSON.stringify(maybeError) || 'Unknown error'); |
| 195 | + if (verbose) console.error('[GlobalError]', error, 'isFatal:', isFatal); |
| 196 | + reportError(error, { isFatal, componentStack: 'global.js', ...baseContext }); |
| 197 | + if (isFatal) showFatalErrorNotification(error); |
| 198 | + if (useSystemAlert) { |
| 199 | + try { Alert.alert('Unexpected Error', 'The app hit a problem but will try to continue.'); } catch {} |
| 200 | + } else if (isFatal) { |
| 201 | + notifyError(error, 'A fatal error occurred. The app may be unstable.'); |
| 202 | + } |
| 203 | + // Call original so RN red screen still appears in dev |
| 204 | + try { originalHandler?.(maybeError, isFatal); } catch {} |
| 205 | + }); |
| 206 | + |
| 207 | + // We can't directly hook native crashes without a module; rely on JS side reporting only. |
| 208 | + if (verbose) console.log('[ErrorHandler] Global handlers initialized (JS only)'); |
| 209 | +} |
| 210 | + |
| 211 | +export function simulateGlobalError(message = 'Simulated error') { |
| 212 | + throw new Error(message); |
| 213 | +} |
| 214 | + |
| 215 | +// Safer async variant that lets the global handler catch it without an immediate red screen |
| 216 | +export function simulateAsyncGlobalError(message = 'Simulated async error') { |
| 217 | + setTimeout(() => { |
| 218 | + throw new Error(message); |
| 219 | + }, 0); |
| 220 | +} |
| 221 | + |
| 222 | +// Single helper to raise a native exception via rn-test-exception-handler |
| 223 | +export function triggerNativeTestException() { |
| 224 | + // Simulate a fatal-style error path; we can't raise a true native crash without a helper lib. |
| 225 | + console.warn('[NativeTest] Simulating native exception (library removed).'); |
| 226 | + // Use setTimeout so global handler catches it like an async native callback. |
| 227 | + setTimeout(() => { |
| 228 | + throw new Error('Simulated Native Exception (stub)'); |
| 229 | + }, 0); |
| 230 | +} |
0 commit comments