@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,212 @@
1
+ import { reportLoggerInternalError, } from '../error-format.ts';
2
+ import { createRecordBuffer, } from './record-buffer.ts';
3
+
4
+ import type {
5
+ Level,
6
+ Sink,
7
+ } from '../types.ts';
8
+
9
+ /**
10
+ * Builds an OPFS sink that buffers serialized records through the shared
11
+ * {@link createRecordBuffer} policy and appends each newline-joined JSONL
12
+ * batch to a per-session file in the Origin Private File System with one
13
+ * stream write per batch. The kept-open writable stream lives in this
14
+ * instance's closure (no module-global state), so independent loggers and
15
+ * tests never share a handle or need a reset hook.
16
+ *
17
+ * Flush triggers (32 KiB in-write cap, `warn`-or-worse severity, 250 ms
18
+ * quiet-period deadline, page lifecycle, and the `flush` hook) are the
19
+ * buffer's; see {@link createRecordBuffer}. Batch writes queue on the stream
20
+ * in issue order, so ordering holds at the batch boundary, and the sink's
21
+ * `flush` hook awaits every issued batch before resolving.
22
+ *
23
+ * @returns Sink backed by OPFS.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const { logger } = createLogger({ sinks: [createOpfsSink()] });
28
+ * logger.warn('quota nearing limit');
29
+ * ```
30
+ */
31
+ export function createOpfsSink(): Sink {
32
+ /**
33
+ * Instance-local kept-open OPFS stream, opened by `verify` and reused by
34
+ * every batch write. Absent until a successful verification.
35
+ */
36
+ const state: { writable?: FileSystemWritableFileStream; } = {};
37
+
38
+ /**
39
+ * Batch writes issued to the stream and not yet settled; the `flush` hook
40
+ * drains this so logger-level `flush()` observes every issued batch.
41
+ */
42
+ const pendingBatchWrites = new Set<Promise<void>>();
43
+
44
+ /**
45
+ * Verifies OPFS is available and round-trips a probe write, then opens the
46
+ * stream reused by subsequent writes. The logger calls this once and owns
47
+ * the resulting availability.
48
+ *
49
+ * @returns Whether OPFS logging is available.
50
+ */
51
+ async function verify(): Promise<boolean> {
52
+ try {
53
+ /**
54
+ * Origin Private File System directory handle that hosts every monochromatic log file.
55
+ */
56
+ const opfsRoot = await navigator.storage
57
+ .getDirectory();
58
+ /**
59
+ * ISO timestamp with colons replaced by dashes so it can be embedded in a cross-platform file name.
60
+ */
61
+ const timestamp = new Date().toISOString()
62
+ .replaceAll(
63
+ ':',
64
+ '-',
65
+ );
66
+ /**
67
+ * OPFS handle for the per-run log file, created on first verification and reused for subsequent writes.
68
+ */
69
+ const fileHandle = await opfsRoot.getFileHandle(
70
+ `monochromatic-${timestamp}.log.jsonl`,
71
+ { create: true, },
72
+ );
73
+ // Write test data and close to flush; getFile() reads stale content
74
+ // while a FileSystemWritableFileStream is still open.
75
+ /**
76
+ * Throwaway writable used only to flush the probe so the next `getFile` returns persisted content.
77
+ */
78
+ const probeWritable = await fileHandle.createWritable({ keepExistingData: true, },);
79
+ /**
80
+ * Probe record written and read back to confirm OPFS round-trips writes.
81
+ */
82
+ const testData = `{"test":true,"timestamp":${Date.now()}}\n`;
83
+ await probeWritable.write(testData,);
84
+ await probeWritable.close();
85
+
86
+ /**
87
+ * File snapshot of the probe, taken after closing `probeWritable` so its bytes are flushed.
88
+ */
89
+ const file = await fileHandle.getFile();
90
+ /**
91
+ * Probe contents read back; matching the literal `"test":true` proves OPFS persisted the data.
92
+ */
93
+ const content = await file.text();
94
+ /**
95
+ * Whether the probe round-tripped; only then is the reused stream opened.
96
+ */
97
+ const available = content.includes('"test":true',);
98
+
99
+ if (available)
100
+ // Reopen for subsequent log writes.
101
+ state.writable = await fileHandle.createWritable({ keepExistingData: true, },);
102
+
103
+ return available;
104
+ }
105
+ catch (error: unknown) {
106
+ /**
107
+ * OPFS storage object, present only when the current platform exposes the backend this sink verifies.
108
+ */
109
+ const opfsStorage = globalThis.navigator
110
+ ?.storage;
111
+ if ((opfsStorage !== undefined) && ('getDirectory' in opfsStorage))
112
+ reportLoggerInternalError({
113
+ context: 'OPFS sink verification failed',
114
+ error,
115
+ },);
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Writes one newline-terminated batch to the OPFS stream, swallowing and
122
+ * reporting failures so the pending-write set always settles.
123
+ *
124
+ * @param batch - Newline-joined JSONL batch from the buffer.
125
+ */
126
+ async function writeBatch(batch: string,): Promise<void> {
127
+ if (!state.writable)
128
+ return;
129
+
130
+ try {
131
+ await state.writable
132
+ .write(`${batch}\n`,);
133
+ }
134
+ catch (error: unknown) {
135
+ reportLoggerInternalError({
136
+ context: 'OPFS sink record write failed',
137
+ error,
138
+ },);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Removes a tracked batch write from {@link pendingBatchWrites} once it
144
+ * settles.
145
+ *
146
+ * @param pending - Promise returned by {@link writeBatch}.
147
+ */
148
+ async function removePendingWhenSettled(pending: Promise<void>,): Promise<void> {
149
+ await pending;
150
+ pendingBatchWrites.delete(pending,);
151
+ }
152
+
153
+ /**
154
+ * Backend handoff for the buffer: issues the batch write without awaiting
155
+ * (stream writes queue in issue order) and tracks it for the `flush` hook.
156
+ *
157
+ * @param batch - Newline-joined JSONL batch from the buffer.
158
+ */
159
+ function handOffBatch(batch: string,): void {
160
+ /**
161
+ * In-flight batch write; never rejects, because {@link writeBatch} reports internally.
162
+ */
163
+ const pending = writeBatch(batch,);
164
+ pendingBatchWrites.add(pending,);
165
+ void removePendingWhenSettled(pending,);
166
+ }
167
+
168
+ /**
169
+ * Shared buffering stage; every flush trigger issues one queued stream
170
+ * write per joined batch.
171
+ */
172
+ const buffer = createRecordBuffer({ onFlush: handOffBatch, },);
173
+
174
+ /**
175
+ * Buffers a log record through the shared policy; see
176
+ * {@link createRecordBuffer} for the flush triggers.
177
+ *
178
+ * @param record - Log record to buffer and eventually append.
179
+ *
180
+ * @mutates record - `JSON.stringify` may invoke `toJSON`, getters, or proxy traps.
181
+ */
182
+ function write(record: {
183
+ level: Level;
184
+ message: string;
185
+ timestamp: number;
186
+ },): Promise<void> {
187
+ buffer.add({
188
+ level: record.level,
189
+ serialized: JSON.stringify(record,),
190
+ },);
191
+ return Promise.resolve();
192
+ }
193
+
194
+ /**
195
+ * Drains the buffer onto the stream and resolves once every issued batch
196
+ * write has settled.
197
+ */
198
+ async function flush(): Promise<void> {
199
+ buffer.drain();
200
+ /**
201
+ * Snapshot of in-flight batch writes at drain time.
202
+ */
203
+ const writes = [...pendingBatchWrites,];
204
+ await Promise.all(writes,);
205
+ }
206
+
207
+ return {
208
+ flush,
209
+ verify,
210
+ write,
211
+ };
212
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ sinks,
8
+ } from '@monochromatic-dev/module-logger';
9
+
10
+ /**
11
+ * Sink factories under test, read from the built artifact's `sinks` namespace.
12
+ */
13
+ const {
14
+ createOpfsSink,
15
+ } = sinks;
16
+
17
+ // Node/Bun has no `navigator.storage`, so this file exercises the
18
+ // unavailable-backend fallback that the browser test (which runs where OPFS
19
+ // exists) never reaches: `getDirectory` throws and is caught, and drained
20
+ // batches hit the unset-stream guard. The available path lives in
21
+ // `opfs.browser.test.ts`; the shared buffering policy is covered in
22
+ // `record-buffer.unit.test.ts`.
23
+ await describe({
24
+ name: 'OPFS sink (node fallback)',
25
+ children: [
26
+ it({
27
+ name: 'verify resolves false when OPFS is absent',
28
+ fn: async () => {
29
+ // `navigator.storage.getDirectory()` dereferences an undefined member
30
+ // and throws; verify catches it and reports the backend unavailable.
31
+ const sink = createOpfsSink();
32
+ expect(await sink.verify(),)
33
+ .toBe(false,);
34
+ },
35
+ },),
36
+
37
+ it({
38
+ name: 'write buffers without throwing when OPFS is absent',
39
+ fn: async () => {
40
+ // The record buffers; nothing touches the missing backend until a
41
+ // flush trigger fires.
42
+ const sink = createOpfsSink();
43
+ /**
44
+ * Resolved write result; the sink write contract is `Promise<void>`.
45
+ */
46
+ const result = await sink.write({
47
+ level: 'info',
48
+ message: 'dropped',
49
+ timestamp: 0,
50
+ },);
51
+ expect(result,)
52
+ .toBeUndefined();
53
+ },
54
+ },),
55
+
56
+ it({
57
+ name: 'flush drains the buffer into the unset-stream guard and resolves',
58
+ fn: async () => {
59
+ // A warn record drains synchronously on add, an info record drains on
60
+ // the flush hook; both batches hit the guard and are dropped silently.
61
+ const sink = createOpfsSink();
62
+ await sink.write({
63
+ level: 'warn',
64
+ message: 'urgent but backendless',
65
+ timestamp: 0,
66
+ },);
67
+ await sink.write({
68
+ level: 'info',
69
+ message: 'buffered but backendless',
70
+ timestamp: 1,
71
+ },);
72
+ /**
73
+ * Resolved flush result; must settle even with no stream to write to.
74
+ */
75
+ const result = await sink.flush?.();
76
+ expect(result,)
77
+ .toBeUndefined();
78
+ },
79
+ },),
80
+ ],
81
+ },);
@@ -0,0 +1,230 @@
1
+ import type { Level, } from '../types.ts';
2
+
3
+ /**
4
+ * Buffered code units that force a synchronous flush from inside `add`
5
+ * itself. 32 KiB sits in the measured flat bottom of the batch-size curve on
6
+ * Chromium 149 and Node 26 (0.15 µs to 1.7 µs per record versus 5 µs to
7
+ * 15 µs unbatched) while staying clear of the measured U-turn where flushes
8
+ * past ~100 KiB cost more per record than not batching; see
9
+ * `doc/troubleshooting/web-storage-sink-main-thread-cost.md`. Because this
10
+ * flush runs synchronously inside `add`, a wedged main thread that keeps
11
+ * logging can never hold more than one cap's worth of unpersisted records.
12
+ */
13
+ const FLUSH_BUFFER_CAP_CHARS = 32_768;
14
+
15
+ /**
16
+ * Quiet-period deadline before a buffered record is flushed by timer, so
17
+ * low-volume sessions still reach the backend without waiting for the byte
18
+ * cap. Each deadline flush costs one backend call, so this cadence is
19
+ * negligible while keeping the loss window for idle periods under a quarter
20
+ * second.
21
+ */
22
+ const FLUSH_DEADLINE_MS = 250;
23
+
24
+ /**
25
+ * Severities that flush the buffer synchronously from inside `add`, so every
26
+ * record up to and including a warning or worse reaches the backend before
27
+ * control returns to the caller. Failure forensics is why persistent sinks
28
+ * exist; these records are rare, so paying the per-batch cost immediately
29
+ * for them does not dent the amortization of the bulk `debug`/`trace`/`info`
30
+ * volume.
31
+ */
32
+ const FLUSH_IMMEDIATELY_BY_LEVEL: Record<Level, boolean> = {
33
+ debug: false,
34
+ error: true,
35
+ fatal: true,
36
+ info: false,
37
+ trace: false,
38
+ warn: true,
39
+ };
40
+
41
+ /**
42
+ * Timer handle exposing Node's keep-alive release. Browsers return a bare
43
+ * number from `setTimeout` and need no release; Node returns an object whose
44
+ * `unref` lets the process exit while the timer is pending.
45
+ */
46
+ type UnrefableTimer = { readonly unref: () => void; };
47
+
48
+ /**
49
+ * Narrows a `setTimeout` return value to a handle exposing `unref`, so a
50
+ * pending deadline flush never pins a server process open past its work.
51
+ *
52
+ * @param timer - Return value of `globalThis.setTimeout`.
53
+ *
54
+ * @returns Whether `timer` exposes a callable `unref`.
55
+ */
56
+ function isUnrefableTimer(timer: unknown,): timer is UnrefableTimer {
57
+ if (((typeof timer) !== 'object') || (timer === null))
58
+ return false;
59
+ if (!('unref' in timer))
60
+ return false;
61
+ return (typeof timer.unref) === 'function';
62
+ }
63
+
64
+ /**
65
+ * Builds the buffering stage shared by batch-persisting sinks: serialized
66
+ * records accumulate and leave as one newline-joined JSONL batch through
67
+ * `onFlush`. One uniform policy runs on every runtime; no per-runtime mode
68
+ * exists.
69
+ *
70
+ * A batch flushes synchronously from inside `add` when it reaches
71
+ * {@link FLUSH_BUFFER_CAP_CHARS} or when the record's severity is `warn` or
72
+ * worse, by timer after {@link FLUSH_DEADLINE_MS} of quiet, on `pagehide`
73
+ * and on the document becoming hidden (where those events exist), and on
74
+ * `drain`. The byte-cap and severity flushes run on the caller's stack, so
75
+ * neither a synchronous workload nor a wedged main thread can accumulate
76
+ * more than one cap of unhanded records. When an addition would breach the
77
+ * cap, the existing entries flush first so an oversized record's downstream
78
+ * failure can only ever drop that record, never its batch-mates.
79
+ *
80
+ * @param onFlush - Backend handoff receiving each newline-joined batch;
81
+ * called synchronously from whichever trigger fires, in record order.
82
+ *
83
+ * @returns Buffer exposing `add` for records and `drain` for forced flushes.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const buffer = createRecordBuffer({ onFlush: (batch) => store.persist(batch) });
88
+ * buffer.add({ level: 'info', serialized: JSON.stringify(record) });
89
+ * buffer.drain();
90
+ * ```
91
+ */
92
+ export function createRecordBuffer(
93
+ { onFlush, }: { readonly onFlush: (batch: string,) => void; },
94
+ ): {
95
+ readonly add: (entry: {
96
+ readonly level: Level;
97
+ readonly serialized: string;
98
+ },) => void;
99
+ readonly drain: () => void;
100
+ } {
101
+ /**
102
+ * Serialized records awaiting one joined handoff; drained in add order by
103
+ * every flush trigger.
104
+ */
105
+ const entries: string[] = [];
106
+
107
+ /**
108
+ * Instance-local buffer bookkeeping. `chars` mirrors the joined length of
109
+ * {@link entries} (records plus one separator between neighbors) so cap
110
+ * checks need no re-summing; `timer`, present only while armed, holds the
111
+ * quiet-period deadline flush so idle sessions still hand off.
112
+ */
113
+ const bufferState: {
114
+ chars: number;
115
+ timer?: ReturnType<typeof globalThis.setTimeout>;
116
+ } = { chars: 0, };
117
+
118
+ /**
119
+ * Joined length the buffer would have after appending `serialized`,
120
+ * counting the newline separator a non-empty buffer needs before it.
121
+ *
122
+ * @param serialized - Record about to be appended.
123
+ *
124
+ * @returns Prospective joined batch length in code units.
125
+ */
126
+ function charsWith(serialized: string,): number {
127
+ /**
128
+ * Newline separator the join adds before this record when the buffer already holds one.
129
+ */
130
+ const separatorChars = (entries.length > 0) ? 1 : 0;
131
+ /**
132
+ * Length of the buffer as currently joined, before this record.
133
+ */
134
+ const joinedChars = bufferState.chars + separatorChars;
135
+ return joinedChars + serialized.length;
136
+ }
137
+
138
+ /**
139
+ * Hands the buffered records to `onFlush` as one newline-joined batch and
140
+ * disarms the deadline timer. Runs synchronously so byte-cap and severity
141
+ * flushes complete on the caller's stack. Safe to call with an empty
142
+ * buffer.
143
+ */
144
+ function drain(): void {
145
+ if (bufferState.timer !== undefined) {
146
+ globalThis.clearTimeout(bufferState.timer,);
147
+ delete bufferState.timer;
148
+ }
149
+ if (entries.length === 0)
150
+ return;
151
+ /**
152
+ * Newline-joined JSONL batch; `JSON.stringify` escapes newlines inside
153
+ * records, so the separator is unambiguous for readers splitting lines.
154
+ */
155
+ const batch = entries.join('\n',);
156
+ entries.length = 0;
157
+ bufferState.chars = 0;
158
+ onFlush(batch,);
159
+ }
160
+
161
+ /**
162
+ * Arms the quiet-period deadline flush if none is pending, releasing the
163
+ * runtime's keep-alive where the handle supports it so a pending flush
164
+ * never holds a process open.
165
+ */
166
+ function scheduleDeadlineFlush(): void {
167
+ if (bufferState.timer !== undefined)
168
+ return;
169
+ /**
170
+ * Freshly armed deadline handle; kept on {@link bufferState} so a cap or severity flush can disarm it.
171
+ */
172
+ const timer = globalThis.setTimeout(
173
+ drain,
174
+ FLUSH_DEADLINE_MS,
175
+ );
176
+ if (isUnrefableTimer(timer,))
177
+ timer.unref();
178
+ bufferState.timer = timer;
179
+ }
180
+
181
+ /**
182
+ * Buffers one serialized record, flushing synchronously when the joined
183
+ * batch reaches the byte cap or the record's severity is `warn` or worse.
184
+ *
185
+ * @param entry - Serialized record plus the severity that decides an
186
+ * immediate flush.
187
+ */
188
+ function add(entry: {
189
+ readonly level: Level;
190
+ readonly serialized: string;
191
+ },): void {
192
+ // Protect batch-mates: an addition that would breach the cap flushes the
193
+ // current entries first, isolating any oversized record in its own batch.
194
+ if ((entries.length > 0) && (charsWith(entry.serialized,) > FLUSH_BUFFER_CAP_CHARS))
195
+ drain();
196
+
197
+ bufferState.chars = charsWith(entry.serialized,);
198
+ entries.push(entry.serialized,);
199
+
200
+ if (FLUSH_IMMEDIATELY_BY_LEVEL[entry.level] || (bufferState.chars >= FLUSH_BUFFER_CAP_CHARS))
201
+ drain();
202
+ else
203
+ scheduleDeadlineFlush();
204
+ }
205
+
206
+ // A leaving or hidden page is the last chance to hand off; both hooks are
207
+ // harmless no-ops on runtimes where the events never fire.
208
+ globalThis.addEventListener?.(
209
+ 'pagehide',
210
+ drain,
211
+ );
212
+ globalThis.document
213
+ ?.addEventListener(
214
+ 'visibilitychange',
215
+ function flushWhenHidden(): void {
216
+ /**
217
+ * Current page visibility; the listener only fires where a document exists.
218
+ */
219
+ const visibility = globalThis.document
220
+ ?.visibilityState;
221
+ if (visibility === 'hidden')
222
+ drain();
223
+ },
224
+ );
225
+
226
+ return {
227
+ add,
228
+ drain,
229
+ };
230
+ }