TypeScript · 4110 bytes Raw Blame History
1 // Frontend error bridge. Installs global handlers that forward JS
2 // errors into Rust tracing (which writes them to
3 // `~/Library/Logs/claudex/claudex.log.<date>`) so a silent React
4 // crash doesn't leave us flying blind.
5 //
6 // What gets captured:
7 // * `window.onerror` — uncaught exceptions from synchronous code
8 // * `window.addEventListener("unhandledrejection")` — rejected
9 // promises with no `.catch()`
10 // * `console.error` / `console.warn` — anything React's error
11 // boundary subsystem funnels through the console, plus our own
12 // explicit `console.error` calls
13 //
14 // Install once from the app entrypoint, before React mounts.
15
16 import { logFrontend, type LogLevel } from "@/lib/ipc/client";
17
18 let installed = false;
19
20 /** Coalesce repeated identical log entries into a single IPC call.
21 * The Tauri bridge fires bursts of 10+ "Couldn't find callback id"
22 * warnings in a couple of ms whenever a listener is unlistened
23 * while events are still in flight — sending each one through
24 * `log_frontend` turns into 10 main-thread IPC round-trips. We
25 * dedupe over a 500 ms window and emit a summary with the count
26 * when it finally flushes. */
27 const COALESCE_WINDOW_MS = 500;
28
29 interface PendingLog {
30 level: LogLevel;
31 source: string;
32 message: string;
33 stack?: string;
34 count: number;
35 timer: ReturnType<typeof setTimeout>;
36 }
37
38 const pendingLogs = new Map<string, PendingLog>();
39
40 function queueLog(
41 level: LogLevel,
42 source: string,
43 message: string,
44 stack?: string,
45 ) {
46 const key = `${level}::${source}::${message}`;
47 const existing = pendingLogs.get(key);
48 if (existing) {
49 existing.count += 1;
50 return;
51 }
52 const timer = setTimeout(() => {
53 const entry = pendingLogs.get(key);
54 pendingLogs.delete(key);
55 if (!entry) return;
56 const msg = entry.count > 1
57 ? `${entry.message}${entry.count})`
58 : entry.message;
59 void logFrontend(entry.level, entry.source, msg, entry.stack);
60 }, COALESCE_WINDOW_MS);
61 pendingLogs.set(key, { level, source, message, stack, count: 1, timer });
62 }
63
64 export function installDebugBridge(): void {
65 if (installed) return;
66 installed = true;
67
68 // Raw sync errors — message + stack end up on disk.
69 window.addEventListener("error", (ev) => {
70 const err = ev.error as unknown;
71 const message = formatMessage(ev.message, err);
72 const stack = extractStack(err);
73 queueLog("error", "window.onerror", message, stack);
74 });
75
76 // Unhandled promise rejections.
77 window.addEventListener("unhandledrejection", (ev) => {
78 const reason = ev.reason as unknown;
79 const message = formatMessage("unhandledrejection", reason);
80 const stack = extractStack(reason);
81 queueLog("error", "unhandledrejection", message, stack);
82 });
83
84 // Shadow console.error / console.warn so anything that goes to
85 // devtools also shows up in the log file. We keep the original
86 // so the devtools output is unchanged.
87 wrapConsole("error");
88 wrapConsole("warn");
89 }
90
91 function wrapConsole(level: "error" | "warn") {
92 const original = console[level].bind(console);
93 const mapped: LogLevel = level;
94 console[level] = (...args: unknown[]) => {
95 original(...args);
96 try {
97 const message = args.map(stringify).join(" ");
98 const stack = args.map(extractStack).find((s): s is string => !!s);
99 queueLog(mapped, `console.${level}`, message, stack);
100 } catch {
101 // Never let logging fail noisily.
102 }
103 };
104 }
105
106 function formatMessage(fallback: string, err: unknown): string {
107 if (err instanceof Error) return err.message || fallback;
108 if (typeof err === "string") return err;
109 if (err == null) return fallback;
110 try {
111 return JSON.stringify(err);
112 } catch {
113 return fallback;
114 }
115 }
116
117 function extractStack(err: unknown): string | undefined {
118 if (err instanceof Error && typeof err.stack === "string") return err.stack;
119 return undefined;
120 }
121
122 function stringify(v: unknown): string {
123 if (v instanceof Error) return `${v.name}: ${v.message}`;
124 if (typeof v === "string") return v;
125 try {
126 return JSON.stringify(v);
127 } catch {
128 return String(v);
129 }
130 }