diff --git a/TODO b/TODO index 4686732..1fad08e 100644 --- a/TODO +++ b/TODO @@ -203,7 +203,7 @@ Content-Type: application/issue ID: 13 Type: feature Title: implement browser local storage handler -Status: open +Status: done Priority: medium Created: 2026-03-13 Relationships: dependsOn:8 diff --git a/src/handler.ts b/src/handler.ts index e174133..6f66f50 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -219,6 +219,114 @@ export class FileHandler extends StreamHandler { } } +/** + * A handler that persists log records to browser localStorage. + * + * Records are stored as a JSON array of formatted strings under a + * configurable key. Log rotation is supported by entry count and/or + * byte size to avoid exceeding storage quotas. + */ +export interface LocalStorageHandlerOptions { + /** + * localStorage key to store entries under. + * Defaults to 'esm-logging'. + */ + key?: string; + + /** + * Maximum number of entries to retain. Oldest entries are discarded + * first. Set to 0 for no entry limit. Defaults to 1000. + */ + maxEntries?: number; + + /** + * Maximum size in bytes (as measured by the JSON-serialized string + * length). Oldest entries are discarded to stay within this limit. + * Set to 0 for no byte limit. Defaults to 0. + */ + maxBytes?: number; +} + +export class LocalStorageHandler extends Handler { + protected key: string; + protected maxEntries: number; + protected maxBytes: number; + + constructor(options?: LocalStorageHandlerOptions) { + super(); + + if (typeof localStorage === 'undefined') { + throw new NotImplementedError( + 'LocalStorageHandler requires a browser environment with localStorage' + ); + } + + this.key = options?.key ?? 'esm-logging'; + this.maxEntries = options?.maxEntries ?? 1000; + this.maxBytes = options?.maxBytes ?? 0; + } + + emit(record: LogRecord) { + try { + const msg = this.format(record); + const entries = this.getEntries(); + entries.push(msg); + this._rotate(entries); + this._setEntries(entries); + } + catch (e) { + this.handleError(record); + } + } + + /** + * Retrieve all stored log entries. + */ + getEntries(): string[] { + try { + const data = localStorage.getItem(this.key); + return data ? JSON.parse(data) : []; + } + catch { + return []; + } + } + + /** + * Remove all stored log entries. + */ + clearEntries() { + localStorage.removeItem(this.key); + } + + close() { + super.close(); + } + + /** + * Discard oldest entries to stay within maxEntries and maxBytes limits. + */ + protected _rotate(entries: string[]) { + if (this.maxEntries > 0) { + while (entries.length > this.maxEntries) { + entries.shift(); + } + } + + if (this.maxBytes > 0) { + while (entries.length > 0) { + const serialized = JSON.stringify(entries); + if (serialized.length <= this.maxBytes) { break } + entries.shift(); + } + } + } + + protected _setEntries(entries: string[]) { + localStorage.setItem(this.key, JSON.stringify(entries)); + } +} + /** * This class is like a StreamHandler using sys.stderr, but always uses * whatever sys.stderr is currently set to rather than the value of diff --git a/tests/unit/local-storage-handler.test.ts b/tests/unit/local-storage-handler.test.ts new file mode 100644 index 0000000..a00b701 --- /dev/null +++ b/tests/unit/local-storage-handler.test.ts @@ -0,0 +1,158 @@ +import {expect, jest, test, beforeEach, afterEach} from '@jest/globals'; +import { LocalStorageHandler } from '../../src/handler'; +import { LogRecord } from '../../src/log-record'; +import { DEBUG, WARNING, ERROR } from '../../src/log-level'; + +// mock localStorage for Node/Jest environment +const store: {[key: string]: string} = {}; +const localStorageMock = { + getItem: (key: string): string | null => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, +}; + +function clearStore() { + for (const key of Object.keys(store)) { delete store[key] } +} + +function makeRecord(level: number, msg: string): LogRecord { + return new LogRecord('test', { level, msg }); +} + +beforeEach(() => { + clearStore(); + (globalThis as any).localStorage = localStorageMock; +}); + +afterEach(() => { + clearStore(); + delete (globalThis as any).localStorage; +}); + +describe('LocalStorageHandler', () => { + describe('constructor', () => { + test('uses default key and limits', () => { + const h = new LocalStorageHandler(); + expect(h.getEntries()).toEqual([]); + }); + + test('throws when localStorage is unavailable', () => { + delete (globalThis as any).localStorage; + expect(() => new LocalStorageHandler()).toThrow( + 'requires a browser environment' + ); + }); + + test('accepts custom options', () => { + const h = new LocalStorageHandler({ + key: 'custom-log', + maxEntries: 50, + maxBytes: 4096, + }); + h.emit(makeRecord(DEBUG, 'test')); + expect(store['custom-log']).toBeDefined(); + expect(store['esm-logging']).toBeUndefined(); + }); + }); + + describe('emit', () => { + test('stores formatted log entry', () => { + const h = new LocalStorageHandler(); + h.emit(makeRecord(WARNING, 'hello')); + const entries = h.getEntries(); + expect(entries.length).toBe(1); + expect(entries[0]).toContain('hello'); + }); + + test('appends multiple entries', () => { + const h = new LocalStorageHandler(); + h.emit(makeRecord(DEBUG, 'first')); + h.emit(makeRecord(DEBUG, 'second')); + h.emit(makeRecord(DEBUG, 'third')); + expect(h.getEntries().length).toBe(3); + }); + + test('persists across handler instances with same key', () => { + const h1 = new LocalStorageHandler({ key: 'shared' }); + h1.emit(makeRecord(DEBUG, 'from h1')); + + const h2 = new LocalStorageHandler({ key: 'shared' }); + const entries = h2.getEntries(); + expect(entries.length).toBe(1); + expect(entries[0]).toContain('from h1'); + }); + }); + + describe('rotation by entry count', () => { + test('discards oldest entries when maxEntries exceeded', () => { + const h = new LocalStorageHandler({ maxEntries: 3 }); + h.emit(makeRecord(DEBUG, 'msg-1')); + h.emit(makeRecord(DEBUG, 'msg-2')); + h.emit(makeRecord(DEBUG, 'msg-3')); + h.emit(makeRecord(DEBUG, 'msg-4')); + + const entries = h.getEntries(); + expect(entries.length).toBe(3); + expect(entries[0]).toContain('msg-2'); + expect(entries[2]).toContain('msg-4'); + }); + + test('maxEntries of 1 keeps only the latest', () => { + const h = new LocalStorageHandler({ maxEntries: 1 }); + h.emit(makeRecord(DEBUG, 'old')); + h.emit(makeRecord(DEBUG, 'new')); + + const entries = h.getEntries(); + expect(entries.length).toBe(1); + expect(entries[0]).toContain('new'); + }); + }); + + describe('rotation by byte size', () => { + test('discards oldest entries when maxBytes exceeded', () => { + const h = new LocalStorageHandler({ maxEntries: 0, maxBytes: 100 }); + // emit entries until we exceed the limit + for (let i = 0; i < 20; i++) { + h.emit(makeRecord(DEBUG, `message-${i}`)); + } + + const entries = h.getEntries(); + const serialized = JSON.stringify(entries); + expect(serialized.length).toBeLessThanOrEqual(100); + expect(entries.length).toBeGreaterThan(0); + }); + }); + + describe('getEntries', () => { + test('returns empty array when no entries', () => { + const h = new LocalStorageHandler(); + expect(h.getEntries()).toEqual([]); + }); + + test('returns empty array on corrupt data', () => { + store['esm-logging'] = 'not valid json{{{'; + const h = new LocalStorageHandler(); + expect(h.getEntries()).toEqual([]); + }); + }); + + describe('clearEntries', () => { + test('removes all stored entries', () => { + const h = new LocalStorageHandler(); + h.emit(makeRecord(DEBUG, 'to be cleared')); + expect(h.getEntries().length).toBe(1); + + h.clearEntries(); + expect(h.getEntries()).toEqual([]); + }); + }); + + describe('close', () => { + test('sets closed to true', () => { + const h = new LocalStorageHandler(); + expect(h.closed).toBe(false); + h.close(); + expect(h.closed).toBe(true); + }); + }); +});