107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
/**
|
|||
|
|
* A tiny ring buffer of the most recent errors, attached to the issue
|
||
|
|
* reports users file from inside the builder. Without it a report says
|
||
|
|
* "it broke" and nothing else.
|
||
|
|
*
|
||
|
|
* Message text only -- no stack traces. Production stacks are minified into
|
||
|
|
* uselessness and leak bundle paths for no diagnostic gain.
|
||
|
|
*
|
||
|
|
* The console.error patch ALWAYS chains to the original. A monitor that
|
||
|
|
* swallows diagnostics is worse than no monitor.
|
||
|
|
*
|
||
|
|
* Idempotency note: installConsoleErrorBuffer() is guarded by a marker
|
||
|
|
* stamped directly on the patched console.error function itself, not just a
|
||
|
|
* module-scoped boolean. React 18 StrictMode (which double-invokes effects)
|
||
|
|
* and hot module reload can re-run this module's top-level code, resetting
|
||
|
|
* any local `let installed = false` while the *global* console.error is
|
||
|
|
* still the already-patched function from the previous install. A
|
||
|
|
* boolean-only guard would then treat that already-patched function as "the
|
||
|
|
* original" and wrap it again, building a chain that grows on every reload.
|
||
|
|
* Checking the marker on the live console.error avoids that regardless of
|
||
|
|
* how many times this module's top level re-executes.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export interface ConsoleErrorEntry {
|
||
|
|
ts: number;
|
||
|
|
message: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MAX_ENTRIES = 20;
|
||
|
|
const MAX_MESSAGE = 500;
|
||
|
|
const MARKER = '__whpConsoleErrorBufferPatched';
|
||
|
|
|
||
|
|
type MarkedConsoleError = typeof console.error & {
|
||
|
|
[MARKER]?: true;
|
||
|
|
__original?: typeof console.error;
|
||
|
|
};
|
||
|
|
|
||
|
|
function isPatched(fn: typeof console.error): fn is MarkedConsoleError {
|
||
|
|
return typeof fn === 'function' && (fn as MarkedConsoleError)[MARKER] === true;
|
||
|
|
}
|
||
|
|
|
||
|
|
let buffer: ConsoleErrorEntry[] = [];
|
||
|
|
let errorListener: ((e: ErrorEvent) => void) | null = null;
|
||
|
|
let rejectionListener: ((e: PromiseRejectionEvent) => void) | null = null;
|
||
|
|
|
||
|
|
function record(message: string): void {
|
||
|
|
const text = message.length > MAX_MESSAGE ? message.slice(0, MAX_MESSAGE) : message;
|
||
|
|
buffer.push({ ts: Date.now(), message: text });
|
||
|
|
if (buffer.length > MAX_ENTRIES) buffer = buffer.slice(buffer.length - MAX_ENTRIES);
|
||
|
|
}
|
||
|
|
|
||
|
|
function stringifyArg(arg: unknown): string {
|
||
|
|
if (typeof arg === 'string') return arg;
|
||
|
|
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
||
|
|
try {
|
||
|
|
return JSON.stringify(arg);
|
||
|
|
} catch {
|
||
|
|
return String(arg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Idempotent. Patches console.error and adds window error listeners. */
|
||
|
|
export function installConsoleErrorBuffer(): void {
|
||
|
|
if (isPatched(console.error)) return;
|
||
|
|
|
||
|
|
const original = console.error.bind(console);
|
||
|
|
const patched: MarkedConsoleError = (...args: unknown[]): void => {
|
||
|
|
try {
|
||
|
|
record(args.map(stringifyArg).join(' '));
|
||
|
|
} catch {
|
||
|
|
// Recording must never break logging.
|
||
|
|
}
|
||
|
|
original(...args);
|
||
|
|
};
|
||
|
|
patched[MARKER] = true;
|
||
|
|
patched.__original = original;
|
||
|
|
console.error = patched;
|
||
|
|
|
||
|
|
if (typeof window !== 'undefined') {
|
||
|
|
errorListener = (e: ErrorEvent) => record(`window.onerror: ${e.message}`);
|
||
|
|
rejectionListener = (e: PromiseRejectionEvent) =>
|
||
|
|
record(`unhandledrejection: ${stringifyArg(e.reason)}`);
|
||
|
|
window.addEventListener('error', errorListener);
|
||
|
|
window.addEventListener('unhandledrejection', rejectionListener);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Oldest-first copy of the retained entries (at most 20). */
|
||
|
|
export function getRecentConsoleErrors(): ConsoleErrorEntry[] {
|
||
|
|
return buffer.slice();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Test-only: clear the buffer and un-patch. */
|
||
|
|
export function __resetConsoleErrorBuffer(): void {
|
||
|
|
buffer = [];
|
||
|
|
const current = console.error;
|
||
|
|
if (isPatched(current) && current.__original) {
|
||
|
|
console.error = current.__original;
|
||
|
|
}
|
||
|
|
if (typeof window !== 'undefined') {
|
||
|
|
if (errorListener) window.removeEventListener('error', errorListener);
|
||
|
|
if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener);
|
||
|
|
}
|
||
|
|
errorListener = null;
|
||
|
|
rejectionListener = null;
|
||
|
|
}
|