@monochromatic-dev/module-logger 0.1.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  3. package/LICENSES/LGPL-3.0-or-later.txt +165 -0
  4. package/README.md +404 -0
  5. package/dist/final/neutral/index.d.mts +673 -0
  6. package/dist/final/neutral/index.mjs +3 -0
  7. package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
  8. package/dist/final/node/index.d.mts +673 -0
  9. package/dist/final/node/index.mjs +3 -0
  10. package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
  11. package/package.json +43 -0
  12. package/src/create-logger.ts +494 -0
  13. package/src/create-logger.unit.test.ts +752 -0
  14. package/src/error-format.ts +43 -0
  15. package/src/index.ts +35 -0
  16. package/src/logger.ts +67 -0
  17. package/src/logger.unit.test.ts +190 -0
  18. package/src/sink/console-control-chars.ts +140 -0
  19. package/src/sink/console-control-chars.unit.test.ts +206 -0
  20. package/src/sink/console.ts +531 -0
  21. package/src/sink/console.unit.test.ts +542 -0
  22. package/src/sink/file.ts +297 -0
  23. package/src/sink/file.unit.test.ts +202 -0
  24. package/src/sink/index.ts +11 -0
  25. package/src/sink/indexed-db-util.ts +96 -0
  26. package/src/sink/indexed-db.browser.test.ts +184 -0
  27. package/src/sink/indexed-db.ts +324 -0
  28. package/src/sink/indexed-db.unit.test.ts +80 -0
  29. package/src/sink/local-storage-key.ts +176 -0
  30. package/src/sink/local-storage-key.unit.test.ts +106 -0
  31. package/src/sink/local-storage-quota.ts +60 -0
  32. package/src/sink/local-storage-quota.unit.test.ts +98 -0
  33. package/src/sink/local-storage-store.ts +368 -0
  34. package/src/sink/local-storage-store.unit.test.ts +329 -0
  35. package/src/sink/local-storage.browser.test.ts +125 -0
  36. package/src/sink/local-storage.ts +182 -0
  37. package/src/sink/local-storage.unit.test.ts +218 -0
  38. package/src/sink/noop.ts +46 -0
  39. package/src/sink/noop.unit.test.ts +47 -0
  40. package/src/sink/opfs.browser.test.ts +84 -0
  41. package/src/sink/opfs.ts +212 -0
  42. package/src/sink/opfs.unit.test.ts +81 -0
  43. package/src/sink/record-buffer.ts +230 -0
  44. package/src/sink/record-buffer.unit.test.ts +288 -0
  45. package/src/sink/session-storage-quota.ts +57 -0
  46. package/src/sink/session-storage-quota.unit.test.ts +98 -0
  47. package/src/sink/session-storage-store.ts +178 -0
  48. package/src/sink/session-storage.browser.test.ts +137 -0
  49. package/src/sink/session-storage.ts +128 -0
  50. package/src/sink/session-storage.unit.test.ts +527 -0
  51. package/src/sink/web-storage-quota-error.ts +43 -0
  52. package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
  53. package/src/sink/web-storage-runtime.ts +49 -0
  54. package/src/startup.unit.test.ts +232 -0
  55. package/src/tagged.ts +74 -0
  56. package/src/tagged.unit.test.ts +211 -0
  57. package/src/types.ts +78 -0
@@ -0,0 +1,531 @@
1
+ import { reportLoggerInternalError, } from '../error-format.ts';
2
+ import { neutralizeControlCharacters, } from './console-control-chars.ts';
3
+
4
+ import type {
5
+ Level,
6
+ LogRecord,
7
+ Sink,
8
+ } from '../types.ts';
9
+
10
+ /**
11
+ * Sentinel for an uncomputed `verboseCache` slot. A unique `Symbol` rather
12
+ * than `null`: the `no-nullish-union` rule bans a nullish "absent" arm, and
13
+ * a real `boolean` value is the computed state this must stay distinct from.
14
+ */
15
+ const VERBOSE_UNCOMPUTED = Symbol('logger:verbose-detection-uncomputed',);
16
+
17
+ /**
18
+ * Levels silenced by default unless verbose mode is active.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * SILENT_LEVELS.has('trace'); // true
23
+ * SILENT_LEVELS.has('info'); // false
24
+ * ```
25
+ */
26
+ const SILENT_LEVELS: ReadonlySet<string> = new Set([
27
+ 'debug',
28
+ 'trace',
29
+ ],);
30
+
31
+ /**
32
+ * Detects verbose mode from environment variables, process arguments,
33
+ * and runtime environment.
34
+ * Checks `process.env.MONOCHROMATIC_VERBOSE`, `process.argv` for `--verbose`,
35
+ * and whether the runtime is a browser.
36
+ * Browser environments enable verbose by default because DevTools
37
+ * already provides its own log-level filtering, making logger-side
38
+ * suppression redundant. Each check is individually guarded
39
+ * so unavailable globals never cause throws.
40
+ *
41
+ * @returns Whether verbose output is enabled.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * // With MONOCHROMATIC_VERBOSE=true in environment
46
+ * detectVerbose(); // true
47
+ * ```
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * // In a browser environment (window is defined)
52
+ * detectVerbose(); // true
53
+ * ```
54
+ */
55
+ function detectVerbose(): boolean {
56
+ try {
57
+ if (((typeof process) !== 'undefined') && (process.env
58
+ .MONOCHROMATIC_VERBOSE
59
+ === 'true'))
60
+ return true;
61
+ }
62
+ catch (error: unknown) {
63
+ reportLoggerInternalError({
64
+ context: 'MONOCHROMATIC_VERBOSE environment probe failed during verbose detection',
65
+ error,
66
+ },);
67
+ }
68
+
69
+ try {
70
+ if (((typeof process) !== 'undefined')
71
+ && Array
72
+ .isArray(process.argv,)
73
+ && process
74
+ .argv
75
+ .includes('--verbose',))
76
+ {
77
+ return true;
78
+ }
79
+ }
80
+ catch (error: unknown) {
81
+ reportLoggerInternalError({
82
+ context: 'process argv probe failed during verbose detection',
83
+ error,
84
+ },);
85
+ }
86
+
87
+ try {
88
+ // Browser DevTools already provides log-level filtering,
89
+ // so suppressing debug/trace at the logger level is redundant.
90
+ if ('window' in globalThis)
91
+ return true;
92
+ }
93
+ catch (error: unknown) {
94
+ reportLoggerInternalError({
95
+ context: 'window probe failed during verbose detection',
96
+ error,
97
+ },);
98
+ }
99
+
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Detects explicit warn suppression via the `MONOCHROMATIC_WARN` environment
105
+ * variable.
106
+ *
107
+ * Setting `MONOCHROMATIC_WARN=false` drops `warn`-level records, for machine-protocol
108
+ * consumers (such as a stdin/stdout codec) whose output streams must stay clean
109
+ * on success. Only the exact string `'false'` suppresses; any other value, or an
110
+ * absent variable, leaves `warn` enabled. Read on each call (not memoized) so a
111
+ * host can toggle it between logs.
112
+ *
113
+ * @returns Whether `warn`-level records are suppressed.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * // With MONOCHROMATIC_WARN=false in environment
118
+ * isWarnSuppressed(); // true
119
+ * ```
120
+ */
121
+ function isWarnSuppressed(): boolean {
122
+ try {
123
+ return ((typeof process) !== 'undefined') && (process.env
124
+ .MONOCHROMATIC_WARN
125
+ === 'false');
126
+ }
127
+ catch (error: unknown) {
128
+ reportLoggerInternalError({
129
+ context: 'MONOCHROMATIC_WARN environment probe failed during warn suppression detection',
130
+ error,
131
+ },);
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Maps log levels to the name of the console method that handles them.
138
+ * Names rather than function references so tests (and other hot patches)
139
+ * that replace `console.info` etc. after module load still see their
140
+ * replacement when the sink flushes. `debug` uses this mapping only when
141
+ * process stderr is unavailable, preserving browser `console.debug` output.
142
+ */
143
+ const LEVEL_TO_CONSOLE_METHOD: Record<Level,
144
+ 'debug' | 'error' | 'info' | 'trace' | 'warn'> = {
145
+ debug: 'debug',
146
+ error: 'error',
147
+ fatal: 'error',
148
+ info: 'info',
149
+ trace: 'trace',
150
+ warn: 'warn',
151
+ };
152
+
153
+ /**
154
+ * Formats a single log record into the display string used by console output.
155
+ * The message passes through {@link neutralizeControlCharacters} first, so a
156
+ * terminal never receives a control sequence smuggled inside log text.
157
+ *
158
+ * @param record - Record to format.
159
+ *
160
+ * @returns Formatted line of the shape `[level] [iso] message`.
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * formatRecord({ level: 'info', message: 'hi', timestamp: 0 });
165
+ * // => '[info] [1970-01-01T00:00:00.000Z] hi'
166
+ * ```
167
+ */
168
+ function formatRecord(record: LogRecord,): string {
169
+ return `[${record.level}] [${
170
+ new Date(record.timestamp,)
171
+ .toISOString()
172
+ }] ${neutralizeControlCharacters(record.message,)}`;
173
+ }
174
+
175
+ /**
176
+ * Detects whether process stderr can receive debug records directly. Kept
177
+ * separate from writing so availability checks can still require
178
+ * `console.debug` when stderr is unavailable and browser fallback is needed.
179
+ *
180
+ * @returns Whether process stderr exposes a callable `write` method.
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * hasProcessStderr();
185
+ * // => true in Node.js and Bun processes
186
+ * ```
187
+ */
188
+ function hasProcessStderr(): boolean {
189
+ try {
190
+ if ((typeof process) === 'undefined')
191
+ return false;
192
+
193
+ return (typeof process
194
+ .stderr
195
+ .write) === 'function';
196
+ }
197
+ catch (error: unknown) {
198
+ reportLoggerInternalError({
199
+ context: 'process stderr availability probe failed',
200
+ error,
201
+ },);
202
+ return false;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Writes a formatted debug run to process stderr when the host exposes a
208
+ * process stream. Falling back to `console.debug` keeps browser and restricted
209
+ * runtimes working when `process` is absent or unusable.
210
+ *
211
+ * @param text - Formatted debug run text that should stay off stdout.
212
+ *
213
+ * @returns Whether process stderr accepted the debug run.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * writeDebugRunToProcessStderr('[debug] [1970-01-01T00:00:00.000Z] hi');
218
+ * // => true when process.stderr.write is available
219
+ * ```
220
+ */
221
+ function writeDebugRunToProcessStderr(text: string,): boolean {
222
+ try {
223
+ if (!hasProcessStderr())
224
+ return false;
225
+
226
+ process.stderr
227
+ .write(`${text}\n`,);
228
+ return true;
229
+ }
230
+ catch (error: unknown) {
231
+ reportLoggerInternalError({
232
+ context: 'debug run process stderr write failed',
233
+ error,
234
+ },);
235
+ return false;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Emits a contiguous run of same-level records as a single console call,
241
+ * joining formatted lines with `\n`. Debug records write to process stderr
242
+ * when `process.stderr.write` is available, keeping stdout clean in CLI hosts.
243
+ *
244
+ * @param records - Records that all share `level`.
245
+ *
246
+ * @param level - Shared severity level whose mapped `console.*` receives
247
+ * the joined text.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * emitRun({ records: [{ level: 'info', message: 'a', timestamp: 0 }], level: 'info' });
252
+ * // calls console.info('[info] [1970-01-01T00:00:00.000Z] a')
253
+ * ```
254
+ */
255
+ function emitRun(
256
+ {
257
+ records,
258
+ level,
259
+ }: {
260
+ readonly records: readonly LogRecord[];
261
+ readonly level: Level;
262
+ },
263
+ ): void {
264
+ /**
265
+ * Joined run text; one `\n`-separated string per console call so a long run becomes a single grouped entry rather than N separate ones.
266
+ */
267
+ const text = records
268
+ .map(function formatOne(r,) {
269
+ return formatRecord(r,);
270
+ },)
271
+ .join('\n',);
272
+ if ((level === 'debug') && writeDebugRunToProcessStderr(text,))
273
+ return;
274
+ try {
275
+ /**
276
+ * Name (not the function reference) of the matching `console.*` method; resolved lazily so post-import hot patches still apply.
277
+ */
278
+ const method = LEVEL_TO_CONSOLE_METHOD[level];
279
+ /**
280
+ * Resolved console method looked up by name; may be missing or non-callable in stripped runtimes, which the guard handles.
281
+ */
282
+ const consoleFn = console[method];
283
+ if ((typeof consoleFn) === 'function') {
284
+ consoleFn.call(
285
+ console,
286
+ text,
287
+ );
288
+ }
289
+ }
290
+ catch (error: unknown) {
291
+ reportLoggerInternalError({
292
+ context: 'console method call failed while emitting log run',
293
+ error,
294
+ },);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * A contiguous slice of buffered records that share one level. Built by
300
+ * {@link groupRuns} and consumed by {@link flushBuffer} to emit one
301
+ * `console.*` call per slice.
302
+ */
303
+ type Run = {
304
+ level: Level;
305
+ records: LogRecord[];
306
+ };
307
+
308
+ /**
309
+ * Groups a record sequence into contiguous same-level runs. Each input
310
+ * record is appended to the trailing run when its level matches, otherwise
311
+ * a new run is opened. A reduce-based collapse keeps the cursor (run head
312
+ * and span) out of mutable function-body locals.
313
+ *
314
+ * @param records - Buffered records in arrival order.
315
+ *
316
+ * @returns Ordered list of runs covering every input record exactly once.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * groupRuns([
321
+ * { level: 'debug', message: 'a', timestamp: 0 },
322
+ * { level: 'debug', message: 'b', timestamp: 0 },
323
+ * { level: 'warn', message: 'c', timestamp: 0 },
324
+ * ]);
325
+ * // => [{ level: 'debug', records: [a, b] }, { level: 'warn', records: [c] }]
326
+ * ```
327
+ */
328
+ function groupRuns(records: readonly LogRecord[],): Run[] {
329
+ return records.reduce<Run[]>(
330
+ function appendToRuns(
331
+ runs,
332
+ record,
333
+ ) {
334
+ /**
335
+ * Trailing run being extended; new same-level records append onto it, otherwise a fresh run is opened.
336
+ */
337
+ const tail = runs.at(-1,);
338
+ if ((tail !== undefined) && (tail.level
339
+ === record
340
+ .level)) {
341
+ tail.records
342
+ .push(record,);
343
+ return runs;
344
+ }
345
+ runs.push({
346
+ level: record.level,
347
+ records: [record,],
348
+ },);
349
+ return runs;
350
+ },
351
+ [],
352
+ );
353
+ }
354
+
355
+ /**
356
+ * Verifies console is available and microtask scheduling is supported.
357
+ * `queueMicrotask` is the batching primitive; without it there is no
358
+ * ordering guarantee that preserves "end of current sync frame" semantics,
359
+ * so the sink reports itself unavailable instead of falling back to an
360
+ * inferior scheduler. Stateless: the logger calls this once and owns the
361
+ * resulting availability.
362
+ *
363
+ * @returns Whether console logging is available.
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * if (await verifyConsole()) {
368
+ * // console usable
369
+ * }
370
+ * ```
371
+ */
372
+ function verifyConsole(): Promise<boolean> {
373
+ try {
374
+ if ((typeof console) === 'undefined')
375
+ return Promise.resolve(false,);
376
+
377
+ /**
378
+ * Sample console method used only to check that debug has an output path:
379
+ * process runtimes use stderr, while fallback runtimes need `console.debug`.
380
+ */
381
+ const testFn = hasProcessStderr() ? console.info : console.debug;
382
+ if ((typeof testFn) !== 'function')
383
+ return Promise.resolve(false,);
384
+
385
+ if ((typeof queueMicrotask) !== 'function')
386
+ return Promise.resolve(false,);
387
+
388
+ return Promise.resolve(true,);
389
+ }
390
+ catch (error: unknown) {
391
+ reportLoggerInternalError({
392
+ context: 'console sink verification failed',
393
+ error,
394
+ },);
395
+ return Promise.resolve(false,);
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Builds a microtask-batched console sink. The pending buffer, schedule flag,
401
+ * and memoized verbose detection live in this instance's closure (no
402
+ * module-global state), so independent loggers and tests stay isolated with
403
+ * no reset hook. Collapses contiguous same-level runs into single `console.*`
404
+ * calls, sharply reducing console-panel overhead when an instrumented path
405
+ * emits many records per sync frame.
406
+ *
407
+ * @returns Sink that writes formatted lines to `console.*`, except
408
+ * process-hosted debug records write to stderr.
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * const { logger } = createLogger({ sinks: [createConsoleSink()] });
413
+ * logger.info('server started');
414
+ * ```
415
+ */
416
+ export function createConsoleSink(): Sink {
417
+ /**
418
+ * Instance-local console-sink state. `buffer` holds records awaiting the
419
+ * next microtask flush; `scheduled` guards against redundant
420
+ * `queueMicrotask` calls within one sync frame; `verboseCache` memoizes
421
+ * verbose detection (the sentinel means not yet computed) so a host can
422
+ * mutate `process.env.MONOCHROMATIC_VERBOSE` before the first log and still be seen.
423
+ */
424
+ const state: {
425
+ buffer: LogRecord[];
426
+ scheduled: boolean;
427
+ verboseCache: boolean | typeof VERBOSE_UNCOMPUTED;
428
+ } = {
429
+ buffer: [],
430
+ scheduled: false,
431
+ verboseCache: VERBOSE_UNCOMPUTED,
432
+ };
433
+
434
+ /**
435
+ * Reads the memoized verbose flag, evaluating via {@link detectVerbose} on
436
+ * first call. Lazy rather than at construction so tests (and hosts) can
437
+ * mutate `process.env.MONOCHROMATIC_VERBOSE` between construction and first log without a
438
+ * stale cache.
439
+ *
440
+ * @returns Whether verbose logging is enabled for this process.
441
+ */
442
+ function getVerbose(): boolean {
443
+ /**
444
+ * Cached verbose flag; the sentinel means detection has not run yet.
445
+ */
446
+ const cached = state.verboseCache;
447
+ if (cached !== VERBOSE_UNCOMPUTED)
448
+ return cached;
449
+ /**
450
+ * Computed verbose flag, stored so subsequent reads skip detection.
451
+ */
452
+ const computed = detectVerbose();
453
+ state.verboseCache = computed;
454
+ return computed;
455
+ }
456
+
457
+ /**
458
+ * Drains the buffer, collapsing contiguous same-level runs (via
459
+ * {@link groupRuns}) into single console calls emitted by {@link emitRun}.
460
+ * A sequence `[debug, debug, warn, debug]` becomes three calls:
461
+ * `process.stderr.write` (two lines joined), `console.warn`, then
462
+ * `process.stderr.write` under process runtimes. Typical instrumented
463
+ * functions use a single level throughout, so most flushes collapse to one
464
+ * call.
465
+ */
466
+ function flushBuffer(): void {
467
+ state.scheduled = false;
468
+ if (state.buffer
469
+ .length
470
+ === 0)
471
+ return;
472
+
473
+ /**
474
+ * Snapshot of buffered records drained before the loop.
475
+ *
476
+ * Using `splice(0)` empties the buffer atomically so any record enqueued
477
+ * during emission lands in the next flush rather than this one.
478
+ */
479
+ const records = state.buffer
480
+ .splice(0,);
481
+ for (const run of groupRuns(records,)) {
482
+ emitRun({
483
+ level: run.level,
484
+ records: run.records,
485
+ },);
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Enqueues a record for microtask-batched emission. Silently discards
491
+ * `debug`/`trace` unless {@link getVerbose} reports verbose mode is active
492
+ * (via `MONOCHROMATIC_VERBOSE=true` env var, `--verbose` argv, or browser environment), and
493
+ * drops `warn` when {@link isWarnSuppressed}.
494
+ *
495
+ * @param record - Log record to write.
496
+ */
497
+ function write(record: LogRecord,): Promise<void> {
498
+ if ((!getVerbose()) && SILENT_LEVELS
499
+ .has(record.level,))
500
+ return Promise.resolve();
501
+
502
+ if ((record.level === 'warn') && isWarnSuppressed())
503
+ return Promise.resolve();
504
+
505
+ state.buffer
506
+ .push(record,);
507
+
508
+ if (!state.scheduled) {
509
+ state.scheduled = true;
510
+ queueMicrotask(flushBuffer,);
511
+ }
512
+
513
+ return Promise.resolve();
514
+ }
515
+
516
+ /**
517
+ * Forces any buffered records through to the console immediately via
518
+ * {@link flushBuffer}. Returns an already-resolved promise so call sites
519
+ * await uniformly with async sinks. Safe to call when the buffer is empty.
520
+ */
521
+ function flush(): Promise<void> {
522
+ flushBuffer();
523
+ return Promise.resolve();
524
+ }
525
+
526
+ return {
527
+ flush,
528
+ verify: verifyConsole,
529
+ write,
530
+ };
531
+ }