export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error'; export type ConsoleLevelFilter = 'all' | ConsoleLevel; export interface ConsoleEntry { id: string; occurredAt: string; level: ConsoleLevel; message: string; data?: unknown; } export interface LiveConsoleState { capacity: number; entries: readonly ConsoleEntry[]; pendingEntries: readonly ConsoleEntry[]; isPaused: boolean; isAutoScrollEnabled: boolean; level: ConsoleLevelFilter; search: string; } export type LiveConsoleAction = | { type: 'append'; entry: ConsoleEntry } | { type: 'set-paused'; paused: boolean } | { type: 'set-auto-scroll'; enabled: boolean } | { type: 'set-level'; level: ConsoleLevelFilter } | { type: 'set-search'; search: string } | { type: 'clear' }; function boundedAppend( entries: readonly ConsoleEntry[], entry: ConsoleEntry, capacity: number, ): readonly ConsoleEntry[] { if (entries.some((candidate) => isSameEntry(candidate, entry))) return entries; return [...entries, entry].slice(-capacity); } function isSameEntry(left: ConsoleEntry, right: ConsoleEntry): boolean { return left.id === right.id || ( left.occurredAt === right.occurredAt && left.level === right.level && left.message === right.message && searchableData(left.data) === searchableData(right.data) ); } export function createLiveConsoleState(capacity = 500): LiveConsoleState { if (!Number.isInteger(capacity) || capacity <= 0) { throw new RangeError('capacity must be a positive integer'); } return { capacity, entries: [], pendingEntries: [], isPaused: false, isAutoScrollEnabled: true, level: 'all', search: '', }; } export function reduceLiveConsoleState( state: LiveConsoleState, action: LiveConsoleAction, ): LiveConsoleState { switch (action.type) { case 'append': { if ( state.entries.some((entry) => isSameEntry(entry, action.entry)) || state.pendingEntries.some((entry) => isSameEntry(entry, action.entry)) ) return state; return state.isPaused ? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) } : { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) }; } case 'set-paused': if (action.paused === state.isPaused) return state; return action.paused ? { ...state, isPaused: true } : { ...state, isPaused: false, entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity), pendingEntries: [], }; case 'set-auto-scroll': return { ...state, isAutoScrollEnabled: action.enabled }; case 'set-level': return { ...state, level: action.level }; case 'set-search': return { ...state, search: action.search }; case 'clear': return { ...state, entries: [], pendingEntries: [] }; } } function searchableData(data: unknown): string { if (data === undefined) return ''; try { return JSON.stringify(data); } catch { return ''; } } export function selectVisibleConsoleEntries( state: LiveConsoleState, ): readonly ConsoleEntry[] { const search = state.search.trim().toLocaleLowerCase(); return state.entries.filter((entry) => { if (state.level !== 'all' && entry.level !== state.level) return false; if (search.length === 0) return true; return `${entry.message} ${searchableData(entry.data)}` .toLocaleLowerCase() .includes(search); }); }