/** * Mandu Kitchen DevTools - Error Catcher * @version 1.0.3 * * 전역 에러를 캐치하여 DevTools로 전달 */ import type { NormalizedError, ErrorType, DevToolsSeverity } from '../../types'; import { getOrCreateHook } from '../../hook'; import { createErrorEvent } from '../../protocol'; // ============================================================================ // Types // ============================================================================ interface ErrorCatcherOptions { /** 캐치할 에러 타입 */ catchTypes?: { windowError?: boolean; unhandledRejection?: boolean; consoleError?: boolean; reactError?: boolean; }; /** 무시할 에러 패턴 */ ignorePatterns?: (string | RegExp)[]; /** 에러 필터 함수 */ filter?: (error: NormalizedError) => boolean; } // ============================================================================ // Constants // ============================================================================ const DEFAULT_OPTIONS: Required = { catchTypes: { windowError: true, unhandledRejection: true, consoleError: false, // 기본 비활성화 (너무 많음) reactError: true, }, ignorePatterns: [ // Chrome extensions /^chrome-extension:\/\//, // React devtools /react-devtools/i, // Source map errors /\.map$/, ], filter: () => true, }; // ============================================================================ // Error Normalizer // ============================================================================ let errorIdCounter = 0; function generateErrorId(): string { return `err-${Date.now()}-${++errorIdCounter}`; } function normalizeError( error: Error | string | unknown, type: ErrorType, extra?: Partial ): NormalizedError { const isError = error instanceof Error; return { id: generateErrorId(), type, severity: determineDevToolsSeverity(type, error), message: isError ? error.message : String(error), stack: isError ? error.stack : undefined, timestamp: Date.now(), url: typeof window !== 'undefined' ? window.location.href : '', ...extra, }; } function determineDevToolsSeverity(type: ErrorType, _error: unknown): DevToolsSeverity { switch (type) { case 'runtime': case 'unhandled': case 'react': return 'error'; case 'network': return 'warning'; case 'hmr': return 'warning'; case 'guard': return 'warning'; default: return 'error'; } } // ============================================================================ // Error Catcher Class // ============================================================================ export class ErrorCatcher { private options: Required; private isAttached = false; private handlers: { windowError?: (event: ErrorEvent) => void; unhandledRejection?: (event: PromiseRejectionEvent) => void; consoleError?: (...args: unknown[]) => void; } = {}; private originalConsoleError?: typeof console.error; constructor(options?: ErrorCatcherOptions) { this.options = { ...DEFAULT_OPTIONS, ...options, catchTypes: { ...DEFAULT_OPTIONS.catchTypes, ...options?.catchTypes, }, }; } // -------------------------------------------------------------------------- // Lifecycle // -------------------------------------------------------------------------- attach(): void { if (this.isAttached || typeof window === 'undefined') return; const { catchTypes } = this.options; if (catchTypes.windowError) { this.attachWindowError(); } if (catchTypes.unhandledRejection) { this.attachUnhandledRejection(); } if (catchTypes.consoleError) { this.attachConsoleError(); } this.isAttached = true; } detach(): void { if (!this.isAttached || typeof window === 'undefined') return; if (this.handlers.windowError) { window.removeEventListener('error', this.handlers.windowError); } if (this.handlers.unhandledRejection) { window.removeEventListener( 'unhandledrejection', this.handlers.unhandledRejection ); } if (this.originalConsoleError) { console.error = this.originalConsoleError; } this.handlers = {}; this.isAttached = false; } // -------------------------------------------------------------------------- // Attach Handlers // -------------------------------------------------------------------------- private attachWindowError(): void { this.handlers.windowError = (event: ErrorEvent) => { const error = normalizeError(event.error ?? event.message, 'runtime', { source: event.filename, line: event.lineno, column: event.colno, }); this.reportError(error); }; window.addEventListener('error', this.handlers.windowError); } private attachUnhandledRejection(): void { this.handlers.unhandledRejection = (event: PromiseRejectionEvent) => { const reason = event.reason; const error = normalizeError(reason, 'unhandled'); this.reportError(error); }; window.addEventListener('unhandledrejection', this.handlers.unhandledRejection); } private attachConsoleError(): void { this.originalConsoleError = console.error; console.error = (...args: unknown[]) => { // 원래 console.error 호출 this.originalConsoleError?.apply(console, args); // 에러로 변환 const message = args .map((arg) => typeof arg === 'object' ? JSON.stringify(arg) : String(arg) ) .join(' '); const error = normalizeError(message, 'runtime', { severity: 'warning', }); this.reportError(error); }; } // -------------------------------------------------------------------------- // Error Processing // -------------------------------------------------------------------------- private shouldIgnore(error: NormalizedError): boolean { const { ignorePatterns, filter } = this.options; // 패턴 매칭 for (const pattern of ignorePatterns) { if (typeof pattern === 'string') { if (error.message.includes(pattern) || error.url.includes(pattern)) { return true; } } else { if ( pattern.test(error.message) || pattern.test(error.url) || (error.source && pattern.test(error.source)) ) { return true; } } } // 필터 함수 if (!filter(error)) { return true; } return false; } private reportError(error: NormalizedError): void { if (this.shouldIgnore(error)) return; const hook = getOrCreateHook(); hook.emit(createErrorEvent(error)); // Kitchen → MCP bridge: POST error to dev server for MCP tool access this.reportToServer(error); } private reportToServer(error: NormalizedError): void { try { fetch("/__kitchen/api/errors", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(error), }).catch(() => { // Silently fail — dev server may not be running }); } catch { // Ignore } } // -------------------------------------------------------------------------- // Manual Reporting // -------------------------------------------------------------------------- /** * 수동으로 에러 리포트 */ report( error: Error | string, options?: Partial> ): void { const normalized = normalizeError(error, options?.type ?? 'runtime', options); this.reportError(normalized); } /** * React Error Boundary에서 호출 */ reportReactError( error: Error, errorInfo: { componentStack?: string } ): void { const normalized = normalizeError(error, 'react', { componentStack: errorInfo.componentStack, }); this.reportError(normalized); } /** * Network 에러 리포트 */ reportNetworkError( url: string, status: number, message: string ): void { const normalized = normalizeError(message, 'network', { source: url, severity: status >= 500 ? 'error' : 'warning', }); this.reportError(normalized); } } // ============================================================================ // Singleton Instance // ============================================================================ let globalErrorCatcher: ErrorCatcher | null = null; export function getErrorCatcher(options?: ErrorCatcherOptions): ErrorCatcher { if (!globalErrorCatcher) { globalErrorCatcher = new ErrorCatcher(options); } return globalErrorCatcher; } export function initializeErrorCatcher(options?: ErrorCatcherOptions): ErrorCatcher { const catcher = getErrorCatcher(options); catcher.attach(); return catcher; } export function destroyErrorCatcher(): void { if (globalErrorCatcher) { globalErrorCatcher.detach(); globalErrorCatcher = null; } }