@buoy-gg/console 7.0.21 → 7.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/preset.js +8 -6
- package/lib/commonjs/preset.js.map +1 -1
- package/lib/commonjs/store/consoleLogStore.js +235 -14
- package/lib/commonjs/store/consoleLogStore.js.map +1 -1
- package/lib/module/preset.js +8 -6
- package/lib/module/preset.js.map +1 -1
- package/lib/module/store/consoleLogStore.js +235 -14
- package/lib/module/store/consoleLogStore.js.map +1 -1
- package/lib/typescript/preset.d.ts.map +1 -1
- package/lib/typescript/store/consoleLogStore.d.ts +8 -7
- package/lib/typescript/store/consoleLogStore.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/preset.tsx +12 -7
- package/src/store/consoleLogStore.ts +300 -26
|
@@ -166,10 +166,87 @@ function captureOwnerStack(): string | undefined {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* An error React Native decorated on its way through ExceptionsManager: a
|
|
171
|
+
* render/lifecycle crash carrying the component stack. `isComponentError` is
|
|
172
|
+
* set by RN's `getExtendedError`, so this identifies a REAL crash rather than
|
|
173
|
+
* an ordinary `console.error("...")` call.
|
|
174
|
+
*/
|
|
175
|
+
type ComponentError = Error & {
|
|
176
|
+
componentStack?: string;
|
|
177
|
+
isComponentError?: boolean;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Frames to keep from a component stack — the crash site plus its context. */
|
|
181
|
+
const COMPONENT_STACK_FRAMES = 8;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Squeeze a React component stack down to the part a human (or an agent
|
|
185
|
+
* reading this over a wire) actually uses: the component names nearest the
|
|
186
|
+
* crash.
|
|
187
|
+
*
|
|
188
|
+
* Raw component stacks in a Metro dev bundle append the full bundle URL with
|
|
189
|
+
* every query param to EVERY frame — one crash is ~15 KB of near-identical
|
|
190
|
+
* `http://192.168.x.x:8830/…&transform.routerRoot=src/app…` noise wrapped
|
|
191
|
+
* around the handful of names that matter. The complete error, componentStack
|
|
192
|
+
* included, is still on the entry's `args` for anyone who wants it.
|
|
193
|
+
*/
|
|
194
|
+
function compactComponentStack(stack: string): string {
|
|
195
|
+
const frames = stack
|
|
196
|
+
.split("\n")
|
|
197
|
+
.map((line) => line.trim())
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
// Drop the "(url)" tail; keep a "Name(./file.tsx)" hint when RN provides one.
|
|
200
|
+
.map((line) => line.replace(/\s*\((?:https?:|\/).*$/, ""));
|
|
201
|
+
const shown = frames.slice(0, COMPONENT_STACK_FRAMES);
|
|
202
|
+
const rest = frames.length - shown.length;
|
|
203
|
+
return (
|
|
204
|
+
shown.map((f) => ` ${f}`).join("\n") +
|
|
205
|
+
(rest > 0 ? `\n …+${rest} more frames` : "")
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
function asComponentError(arg: unknown): ComponentError | null {
|
|
209
|
+
if (!(arg instanceof Error)) return null;
|
|
210
|
+
const e = arg as ComponentError;
|
|
211
|
+
return e.isComponentError || typeof e.componentStack === "string" ? e : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
169
214
|
function record(method: ConsoleMethod, args: unknown[]): void {
|
|
170
215
|
// Capture the call site for every log so we can attribute its origin.
|
|
171
216
|
const rawStack = new Error().stack;
|
|
172
217
|
const origin = parseOrigin(rawStack, captureOwnerStack());
|
|
218
|
+
|
|
219
|
+
// React Native funnels an uncaught render error back through console.error
|
|
220
|
+
// (ExceptionsManager.reportException) so console monkey-patches still see it.
|
|
221
|
+
// That arrives as an ordinary error row, which badly under-sells it: the app
|
|
222
|
+
// is dead. Promote it to a tagged [FATAL] entry carrying the component stack.
|
|
223
|
+
const crash = method === "error" ? asComponentError(args[0]) : null;
|
|
224
|
+
if (crash) {
|
|
225
|
+
// Tagged [RENDER ERROR], not [FATAL]: React reports an error an
|
|
226
|
+
// ErrorBoundary CAUGHT and recovered from through this same path, with the
|
|
227
|
+
// same componentStack, and nothing here distinguishes the two. Claiming a
|
|
228
|
+
// healthy app "most likely CRASHED" is worse than under-claiming — the
|
|
229
|
+
// component stack, which is the useful part, is reported either way.
|
|
230
|
+
const headline = fatalKey(crash.name, crash.message);
|
|
231
|
+
const located = crash.componentStack
|
|
232
|
+
? `\n\nThis error is located at:\n${compactComponentStack(crash.componentStack)}`
|
|
233
|
+
: "";
|
|
234
|
+
pushFatal(
|
|
235
|
+
{
|
|
236
|
+
id: nextId++,
|
|
237
|
+
method: "error",
|
|
238
|
+
level: "error",
|
|
239
|
+
args: ["[RENDER ERROR]", crash],
|
|
240
|
+
message: `[RENDER ERROR] ${headline}${located}`,
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
stack: crash.stack,
|
|
243
|
+
origin,
|
|
244
|
+
},
|
|
245
|
+
isDuplicateFatal(`[RENDER ERROR] ${headline}`)
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
173
250
|
const entry: ConsoleLogEntry = {
|
|
174
251
|
id: nextId++,
|
|
175
252
|
method,
|
|
@@ -186,6 +263,11 @@ function record(method: ConsoleMethod, args: unknown[]): void {
|
|
|
186
263
|
: [...entries, entry];
|
|
187
264
|
emit();
|
|
188
265
|
scheduleSave();
|
|
266
|
+
// Errors are the entries most likely to be the last thing an app ever logs,
|
|
267
|
+
// so they get pushed to any dashboard immediately instead of waiting on the
|
|
268
|
+
// throttle that a dying tree will never run. Rate-limited; ordinary logs
|
|
269
|
+
// still ride the normal throttled path.
|
|
270
|
+
if (entry.level === "error") flushConsoleSyncNow();
|
|
189
271
|
}
|
|
190
272
|
|
|
191
273
|
/** Minimal shape of React Native's global `ErrorUtils`. */
|
|
@@ -198,52 +280,244 @@ interface RNErrorUtils {
|
|
|
198
280
|
) => void;
|
|
199
281
|
}
|
|
200
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Two independent seams report the same crash on some setups (the C++ handler
|
|
285
|
+
* can also feed `console.error`). Suppress a repeat of the same headline within
|
|
286
|
+
* this window so a crash reads as ONE entry.
|
|
287
|
+
*/
|
|
288
|
+
const FATAL_DEDUPE_MS = 2000;
|
|
289
|
+
let lastFatalKey = "";
|
|
290
|
+
let lastFatalAt = 0;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Normalize a crash headline so the same error reported through two different
|
|
294
|
+
* seams produces the same key. React Native often sets `error.message` to the
|
|
295
|
+
* ALREADY-prefixed string ("TypeError: x is not a function"), so one path
|
|
296
|
+
* builds "TypeError: TypeError: x…" and the other "TypeError: x…" — which
|
|
297
|
+
* defeats the de-duplication entirely.
|
|
298
|
+
*/
|
|
299
|
+
function fatalKey(name: string | undefined, message: string): string {
|
|
300
|
+
const body = message.trim();
|
|
301
|
+
if (name && body.startsWith(`${name}: `)) return body;
|
|
302
|
+
return name ? `${name}: ${body}` : body;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Has this crash already been recorded moments ago?
|
|
307
|
+
*
|
|
308
|
+
* Only ever used to suppress a duplicate PUSH to the dashboard — never to drop
|
|
309
|
+
* an entry. Two different components crashing with the same message inside the
|
|
310
|
+
* window are two crashes, and a tool whose pitch is "the app's last words"
|
|
311
|
+
* must not silently throw one away.
|
|
312
|
+
*/
|
|
313
|
+
function isDuplicateFatal(key: string): boolean {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
if (key === lastFatalKey && now - lastFatalAt < FATAL_DEDUPE_MS) return true;
|
|
316
|
+
lastFatalKey = key;
|
|
317
|
+
lastFatalAt = now;
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The device sync bridge's "push this tool NOW" escape hatch
|
|
323
|
+
* (@buoy-gg/external-sync `crashFlush`). Reached through the global rather than
|
|
324
|
+
* an import because console deliberately has no dependency on external-sync.
|
|
325
|
+
*/
|
|
326
|
+
let lastFlushAt = 0;
|
|
327
|
+
/** Floor between opportunistic flushes, so an error storm can't flood the socket. */
|
|
328
|
+
const FLUSH_MIN_INTERVAL_MS = 150;
|
|
329
|
+
const flushConsoleSyncNow = (force = false): void => {
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
// A fatal is the app's last word — it always goes out, rate limit or not.
|
|
332
|
+
if (!force && now - lastFlushAt < FLUSH_MIN_INTERVAL_MS) return;
|
|
333
|
+
lastFlushAt = now;
|
|
334
|
+
try {
|
|
335
|
+
const set = (globalThis as { __buoyFlushToolSync?: Set<(toolId: string) => void> })
|
|
336
|
+
.__buoyFlushToolSync;
|
|
337
|
+
if (!set) return;
|
|
338
|
+
for (const flush of set) flush("console");
|
|
339
|
+
} catch {
|
|
340
|
+
// Best-effort: a crash path must never crash.
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Append a pre-built fatal entry to the ring buffer, then push it to any
|
|
346
|
+
* connected dashboard IMMEDIATELY.
|
|
347
|
+
*
|
|
348
|
+
* A fatal render error unmounts the React tree microseconds later, taking the
|
|
349
|
+
* sync bridge — and the normal 200 ms throttled snapshot — with it. Flushing
|
|
350
|
+
* synchronously here is what makes a crashed app's last words readable from
|
|
351
|
+
* the desktop app and the MCP server instead of the app just going silent.
|
|
352
|
+
*/
|
|
353
|
+
function pushFatal(entry: ConsoleLogEntry, duplicate = false): void {
|
|
354
|
+
entries =
|
|
355
|
+
entries.length >= MAX_ENTRIES
|
|
356
|
+
? [...entries.slice(entries.length - MAX_ENTRIES + 1), entry]
|
|
357
|
+
: [...entries, entry];
|
|
358
|
+
emit();
|
|
359
|
+
scheduleSave();
|
|
360
|
+
// A repeat within the dedupe window is still RECORDED (two crashes are two
|
|
361
|
+
// crashes); it just doesn't earn another forced push to the dashboard.
|
|
362
|
+
if (!duplicate) flushConsoleSyncNow(true);
|
|
363
|
+
}
|
|
364
|
+
|
|
201
365
|
/** Record an uncaught JS error from the global handler as an error entry. */
|
|
202
366
|
function recordFatalError(error: unknown, isFatal?: boolean): void {
|
|
203
367
|
const err = error instanceof Error ? error : undefined;
|
|
204
368
|
// RN passes isFatal:false for non-fatal uncaught errors (e.g. errors thrown
|
|
205
369
|
// in event handlers on some setups); anything else is treated as fatal.
|
|
206
370
|
const label = isFatal === false ? "[UNCAUGHT]" : "[FATAL]";
|
|
207
|
-
const headline = err ?
|
|
371
|
+
const headline = err ? fatalKey(err.name, err.message) : formatArg(error);
|
|
372
|
+
const duplicate = isDuplicateFatal(`${label} ${headline}`);
|
|
208
373
|
// The label leads the args (not just the pre-rendered message): the
|
|
209
374
|
// DevTools-style row renders from args, so this is what makes the tag
|
|
210
375
|
// visible in the UI — exactly like `console.error("[FATAL]", err)`.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
: [...entries, entry];
|
|
226
|
-
emit();
|
|
227
|
-
scheduleSave();
|
|
376
|
+
pushFatal(
|
|
377
|
+
{
|
|
378
|
+
id: nextId++,
|
|
379
|
+
method: "error",
|
|
380
|
+
level: "error",
|
|
381
|
+
args: [label, error],
|
|
382
|
+
message: `${label} ${headline}`,
|
|
383
|
+
timestamp: Date.now(),
|
|
384
|
+
// The error's OWN stack — the crash site, not this handler's call site.
|
|
385
|
+
stack: err?.stack,
|
|
386
|
+
fatal: true,
|
|
387
|
+
},
|
|
388
|
+
duplicate
|
|
389
|
+
);
|
|
228
390
|
}
|
|
229
391
|
|
|
230
392
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
393
|
+
* The exception payload React Native's C++ error handler hands to listeners
|
|
394
|
+
* registered via `global.RN$registerExceptionListener` (JsErrorHandler.cpp).
|
|
395
|
+
*/
|
|
396
|
+
interface NativeExceptionData {
|
|
397
|
+
message?: string;
|
|
398
|
+
originalMessage?: string | null;
|
|
399
|
+
name?: string | null;
|
|
400
|
+
componentStack?: string | null;
|
|
401
|
+
stack?: Array<{
|
|
402
|
+
file?: string;
|
|
403
|
+
methodName?: string;
|
|
404
|
+
lineNumber?: number;
|
|
405
|
+
column?: number;
|
|
406
|
+
}>;
|
|
407
|
+
id?: number;
|
|
408
|
+
isFatal?: boolean;
|
|
409
|
+
isComponentError?: boolean;
|
|
410
|
+
extraData?: { rawStack?: string } & Record<string, unknown>;
|
|
411
|
+
/** Suppresses RN's own redbox. We never call it — we only observe. */
|
|
412
|
+
preventDefault?: () => unknown;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Render the C++ handler's parsed frames back into a normal stack string. */
|
|
416
|
+
function formatNativeStack(data: NativeExceptionData): string | undefined {
|
|
417
|
+
const raw = data.extraData?.rawStack;
|
|
418
|
+
if (typeof raw === "string" && raw.length > 0) return raw;
|
|
419
|
+
if (!Array.isArray(data.stack) || data.stack.length === 0) return undefined;
|
|
420
|
+
return data.stack
|
|
421
|
+
.map((f) => {
|
|
422
|
+
const where =
|
|
423
|
+
f.lineNumber != null ? `${f.file ?? "?"}:${f.lineNumber}:${f.column ?? 0}` : f.file ?? "?";
|
|
424
|
+
return ` at ${f.methodName || "<anonymous>"} (${where})`;
|
|
425
|
+
})
|
|
426
|
+
.join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Record a crash reported by React Native's native (C++) error handler. */
|
|
430
|
+
function recordNativeException(data: NativeExceptionData): void {
|
|
431
|
+
const label = data.isFatal === false ? "[UNCAUGHT]" : "[FATAL]";
|
|
432
|
+
const headline = fatalKey(data.name ?? undefined, data.originalMessage ?? data.message ?? "Unknown error");
|
|
433
|
+
const duplicate = isDuplicateFatal(`${label} ${headline}`);
|
|
434
|
+
// The component stack is the single most useful line for "which component
|
|
435
|
+
// crashed" — RN puts it in the redbox, so put it in the console entry too.
|
|
436
|
+
const componentStack =
|
|
437
|
+
typeof data.componentStack === "string" && data.componentStack.trim()
|
|
438
|
+
? `\n\nThis error is located at:\n${compactComponentStack(data.componentStack)}`
|
|
439
|
+
: "";
|
|
440
|
+
const message = `${label} ${headline}${componentStack}`;
|
|
441
|
+
pushFatal(
|
|
442
|
+
{
|
|
443
|
+
id: nextId++,
|
|
444
|
+
method: "error",
|
|
445
|
+
level: "error",
|
|
446
|
+
args: [label, headline, ...(componentStack ? [componentStack.trim()] : [])],
|
|
447
|
+
message,
|
|
448
|
+
timestamp: Date.now(),
|
|
449
|
+
stack: formatNativeStack(data),
|
|
450
|
+
fatal: true,
|
|
451
|
+
},
|
|
452
|
+
duplicate
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Register with React Native's native (C++) exception handler — the seam a
|
|
458
|
+
* RENDER crash actually travels through on the New Architecture.
|
|
459
|
+
*
|
|
460
|
+
* React 19 routes an uncaught render error to `onUncaughtError` →
|
|
461
|
+
* `ExceptionsManager.handleException`, which NEVER reaches
|
|
462
|
+
* `ErrorUtils.setGlobalHandler`. Where it goes from there depends on the build:
|
|
463
|
+
*
|
|
464
|
+
* - `useAlwaysAvailableJSErrorHandling` on: the C++ handler
|
|
465
|
+
* (`global.RN$handleException`) reports it and JS sees nothing — this
|
|
466
|
+
* listener is the only seam.
|
|
467
|
+
* - otherwise: JS `reportException` re-feeds `console.error(e)` "so any methods
|
|
468
|
+
* monkey patched on top of console.error are called", which the console patch
|
|
469
|
+
* catches and promotes to `[FATAL]` (see `record`).
|
|
470
|
+
*
|
|
471
|
+
* Both are covered, and a crash reported through both de-duplicates to one
|
|
472
|
+
* entry. `RN$registerExceptionListener` keeps a LIST of listeners (LogBox
|
|
473
|
+
* registers one too), so this is purely additive. We never call the payload's
|
|
474
|
+
* `preventDefault()`: suppressing RN's own redbox is not our call to make.
|
|
475
|
+
*/
|
|
476
|
+
function installNativeExceptionCapture(): boolean {
|
|
477
|
+
const register = (
|
|
478
|
+
globalThis as {
|
|
479
|
+
RN$registerExceptionListener?: (
|
|
480
|
+
listener: (data: NativeExceptionData) => void
|
|
481
|
+
) => void;
|
|
482
|
+
}
|
|
483
|
+
).RN$registerExceptionListener;
|
|
484
|
+
if (typeof register !== "function") return false;
|
|
485
|
+
register((data) => {
|
|
486
|
+
try {
|
|
487
|
+
if (!captureDisabledByUser) recordNativeException(data);
|
|
488
|
+
} catch {
|
|
489
|
+
// Capture must never interfere with error reporting.
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Hook React Native's error paths so FATAL/uncaught JS errors land in the
|
|
497
|
+
* console store: the classic `ErrorUtils.setGlobalHandler` (old architecture,
|
|
498
|
+
* native-module callbacks, promise rejections) AND the native C++ exception
|
|
499
|
+
* listener (New Architecture render crashes — see
|
|
500
|
+
* {@link installNativeExceptionCapture}). Both feed the same de-duplicated
|
|
501
|
+
* `[FATAL]` entry, so a crash reported through both seams shows up once.
|
|
236
502
|
*
|
|
237
503
|
* - Chains the previous handler (LogBox/redbox still fire; nothing swallowed).
|
|
238
|
-
* - Idempotent, and a silent no-op off React Native (
|
|
504
|
+
* - Idempotent, and a silent no-op off React Native (neither global present) —
|
|
239
505
|
* which also keeps it inert on the desktop dashboard / web mirrors.
|
|
240
506
|
* - Recording respects an explicit `uninstallConsoleCapture()` (user OFF), but
|
|
241
|
-
* the
|
|
507
|
+
* the hooks themselves stay chained — they can't be safely removed.
|
|
242
508
|
*/
|
|
243
509
|
export function installFatalErrorCapture(): void {
|
|
244
510
|
if (fatalInstalled) return;
|
|
511
|
+
// Latch only once a seam is actually attached. Setting the flag up front
|
|
512
|
+
// means an install that runs before `InitializeCore` has published
|
|
513
|
+
// `ErrorUtils` would latch anyway and never retry — silently losing promise
|
|
514
|
+
// rejections and native-callback errors, which travel ONLY that seam.
|
|
515
|
+
const nativeInstalled = installNativeExceptionCapture();
|
|
245
516
|
const errorUtils = (globalThis as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
|
|
246
|
-
if (typeof errorUtils?.setGlobalHandler !== "function")
|
|
517
|
+
if (typeof errorUtils?.setGlobalHandler !== "function") {
|
|
518
|
+
fatalInstalled = nativeInstalled;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
247
521
|
fatalInstalled = true;
|
|
248
522
|
const previous =
|
|
249
523
|
typeof errorUtils.getGlobalHandler === "function"
|