@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.
- package/CHANGELOG.md +11 -0
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSES/LGPL-3.0-or-later.txt +165 -0
- package/README.md +404 -0
- package/dist/final/neutral/index.d.mts +673 -0
- package/dist/final/neutral/index.mjs +3 -0
- package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/dist/final/node/index.d.mts +673 -0
- package/dist/final/node/index.mjs +3 -0
- package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/package.json +43 -0
- package/src/create-logger.ts +494 -0
- package/src/create-logger.unit.test.ts +752 -0
- package/src/error-format.ts +43 -0
- package/src/index.ts +35 -0
- package/src/logger.ts +67 -0
- package/src/logger.unit.test.ts +190 -0
- package/src/sink/console-control-chars.ts +140 -0
- package/src/sink/console-control-chars.unit.test.ts +206 -0
- package/src/sink/console.ts +531 -0
- package/src/sink/console.unit.test.ts +542 -0
- package/src/sink/file.ts +297 -0
- package/src/sink/file.unit.test.ts +202 -0
- package/src/sink/index.ts +11 -0
- package/src/sink/indexed-db-util.ts +96 -0
- package/src/sink/indexed-db.browser.test.ts +184 -0
- package/src/sink/indexed-db.ts +324 -0
- package/src/sink/indexed-db.unit.test.ts +80 -0
- package/src/sink/local-storage-key.ts +176 -0
- package/src/sink/local-storage-key.unit.test.ts +106 -0
- package/src/sink/local-storage-quota.ts +60 -0
- package/src/sink/local-storage-quota.unit.test.ts +98 -0
- package/src/sink/local-storage-store.ts +368 -0
- package/src/sink/local-storage-store.unit.test.ts +329 -0
- package/src/sink/local-storage.browser.test.ts +125 -0
- package/src/sink/local-storage.ts +182 -0
- package/src/sink/local-storage.unit.test.ts +218 -0
- package/src/sink/noop.ts +46 -0
- package/src/sink/noop.unit.test.ts +47 -0
- package/src/sink/opfs.browser.test.ts +84 -0
- package/src/sink/opfs.ts +212 -0
- package/src/sink/opfs.unit.test.ts +81 -0
- package/src/sink/record-buffer.ts +230 -0
- package/src/sink/record-buffer.unit.test.ts +288 -0
- package/src/sink/session-storage-quota.ts +57 -0
- package/src/sink/session-storage-quota.unit.test.ts +98 -0
- package/src/sink/session-storage-store.ts +178 -0
- package/src/sink/session-storage.browser.test.ts +137 -0
- package/src/sink/session-storage.ts +128 -0
- package/src/sink/session-storage.unit.test.ts +527 -0
- package/src/sink/web-storage-quota-error.ts +43 -0
- package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
- package/src/sink/web-storage-runtime.ts +49 -0
- package/src/startup.unit.test.ts +232 -0
- package/src/tagged.ts +74 -0
- package/src/tagged.unit.test.ts +211 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import { wait, } from '@monochromatic-dev/module-async-time/ts';
|
|
7
|
+
import {
|
|
8
|
+
_createRecordBuffer as createRecordBuffer,
|
|
9
|
+
type Level,
|
|
10
|
+
} from '@monochromatic-dev/module-logger';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds a capturing backend handoff so a test can assert exactly which
|
|
14
|
+
* batches left the buffer and in what shape.
|
|
15
|
+
*
|
|
16
|
+
* @returns Handoff function plus the captured batch list.
|
|
17
|
+
*/
|
|
18
|
+
function createCapturingFlush(): {
|
|
19
|
+
readonly batches: string[];
|
|
20
|
+
readonly onFlush: (batch: string,) => void;
|
|
21
|
+
} {
|
|
22
|
+
const batches: string[] = [];
|
|
23
|
+
return {
|
|
24
|
+
batches,
|
|
25
|
+
onFlush: function captureBatch(batch: string,): void {
|
|
26
|
+
batches.push(batch,);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Installs a capturing `globalThis.addEventListener` (absent under Node, so
|
|
33
|
+
* the buffer's lifecycle registration is otherwise a no-op there), restoring
|
|
34
|
+
* the prior value when the returned guard leaves `using` scope.
|
|
35
|
+
*
|
|
36
|
+
* @returns Disposable exposing captured handlers by event type.
|
|
37
|
+
*/
|
|
38
|
+
function installFakeGlobalListeners(): Disposable & {
|
|
39
|
+
readonly handlers: Map<string, () => void>;
|
|
40
|
+
} {
|
|
41
|
+
const original = globalThis.addEventListener;
|
|
42
|
+
const handlers = new Map<string, () => void>();
|
|
43
|
+
globalThis.addEventListener = (function captureListener(
|
|
44
|
+
type: string,
|
|
45
|
+
handler: () => void,
|
|
46
|
+
): void {
|
|
47
|
+
handlers.set(type, handler,);
|
|
48
|
+
}) as unknown as typeof globalThis.addEventListener;
|
|
49
|
+
return {
|
|
50
|
+
handlers,
|
|
51
|
+
[Symbol.dispose](): void {
|
|
52
|
+
globalThis.addEventListener = original;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Installs a fake `globalThis.document` (absent under Node) whose
|
|
59
|
+
* `visibilityState` is controllable and whose `addEventListener` captures
|
|
60
|
+
* handlers, restoring the prior value when the returned guard leaves `using`
|
|
61
|
+
* scope.
|
|
62
|
+
*
|
|
63
|
+
* @returns Disposable exposing captured handlers and mutable visibility.
|
|
64
|
+
*/
|
|
65
|
+
function installFakeDocument(): Disposable & {
|
|
66
|
+
readonly handlers: Map<string, () => void>;
|
|
67
|
+
readonly visibility: { state: string; };
|
|
68
|
+
} {
|
|
69
|
+
const original = globalThis.document;
|
|
70
|
+
const handlers = new Map<string, () => void>();
|
|
71
|
+
const visibility = { state: 'visible', };
|
|
72
|
+
globalThis.document = {
|
|
73
|
+
addEventListener: function captureListener(
|
|
74
|
+
type: string,
|
|
75
|
+
handler: () => void,
|
|
76
|
+
): void {
|
|
77
|
+
handlers.set(type, handler,);
|
|
78
|
+
},
|
|
79
|
+
get visibilityState(): string {
|
|
80
|
+
return visibility.state;
|
|
81
|
+
},
|
|
82
|
+
} as unknown as Document;
|
|
83
|
+
return {
|
|
84
|
+
handlers,
|
|
85
|
+
visibility,
|
|
86
|
+
[Symbol.dispose](): void {
|
|
87
|
+
globalThis.document = original;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Severities the buffer must flush synchronously on `add`.
|
|
94
|
+
*/
|
|
95
|
+
const URGENT_LEVELS: readonly Level[] = ['warn', 'error', 'fatal',];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Severities the buffer must keep buffered on `add`.
|
|
99
|
+
*/
|
|
100
|
+
const ROUTINE_LEVELS: readonly Level[] = ['trace', 'debug', 'info',];
|
|
101
|
+
|
|
102
|
+
// Serial because the lifecycle tests swap `globalThis` members, and because
|
|
103
|
+
// every buffer registers a process-wide pagehide listener, so a concurrent
|
|
104
|
+
// dispatch could drain a sibling test's buffer mid-assertion.
|
|
105
|
+
await describe({
|
|
106
|
+
name: createRecordBuffer.name,
|
|
107
|
+
concurrency: 1,
|
|
108
|
+
children: [
|
|
109
|
+
it({
|
|
110
|
+
name: 'a routine record stays buffered until drain, then leaves verbatim',
|
|
111
|
+
fn: async () => {
|
|
112
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
113
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
114
|
+
|
|
115
|
+
buffer.add({ level: 'info', serialized: 'r1', },);
|
|
116
|
+
expect(batches.length,)
|
|
117
|
+
.toBe(0,);
|
|
118
|
+
|
|
119
|
+
buffer.drain();
|
|
120
|
+
expect(batches.join('|',),)
|
|
121
|
+
.toBe('r1',);
|
|
122
|
+
},
|
|
123
|
+
},),
|
|
124
|
+
|
|
125
|
+
it({
|
|
126
|
+
name: 'drain joins buffered records with newlines in add order',
|
|
127
|
+
fn: async () => {
|
|
128
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
129
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
130
|
+
|
|
131
|
+
buffer.add({ level: 'info', serialized: 'r1', },);
|
|
132
|
+
buffer.add({ level: 'debug', serialized: 'r2', },);
|
|
133
|
+
buffer.add({ level: 'trace', serialized: 'r3', },);
|
|
134
|
+
buffer.drain();
|
|
135
|
+
|
|
136
|
+
expect(batches.join('|',),)
|
|
137
|
+
.toBe('r1\nr2\nr3',);
|
|
138
|
+
},
|
|
139
|
+
},),
|
|
140
|
+
|
|
141
|
+
...URGENT_LEVELS.map(function mapUrgent(level,) {
|
|
142
|
+
return it({
|
|
143
|
+
name: `a ${level} record flushes itself and everything buffered ahead of it`,
|
|
144
|
+
fn: async () => {
|
|
145
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
146
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
147
|
+
|
|
148
|
+
buffer.add({ level: 'info', serialized: 'ahead', },);
|
|
149
|
+
buffer.add({ level, serialized: 'urgent', },);
|
|
150
|
+
|
|
151
|
+
expect(batches.join('|',),)
|
|
152
|
+
.toBe('ahead\nurgent',);
|
|
153
|
+
},
|
|
154
|
+
},);
|
|
155
|
+
},),
|
|
156
|
+
|
|
157
|
+
...ROUTINE_LEVELS.map(function mapRoutine(level,) {
|
|
158
|
+
return it({
|
|
159
|
+
name: `a ${level} record does not flush on add`,
|
|
160
|
+
fn: async () => {
|
|
161
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
162
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
163
|
+
|
|
164
|
+
buffer.add({ level, serialized: 'routine', },);
|
|
165
|
+
expect(batches.length,)
|
|
166
|
+
.toBe(0,);
|
|
167
|
+
|
|
168
|
+
// Leave nothing armed behind for later tests.
|
|
169
|
+
buffer.drain();
|
|
170
|
+
},
|
|
171
|
+
},);
|
|
172
|
+
},),
|
|
173
|
+
|
|
174
|
+
it({
|
|
175
|
+
name: 'reaching the byte cap flushes synchronously from inside add',
|
|
176
|
+
fn: async () => {
|
|
177
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
178
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
179
|
+
|
|
180
|
+
// A single record past the 32 KiB cap leaves immediately on add.
|
|
181
|
+
buffer.add({ level: 'info', serialized: 'L'.repeat(40_000,), },);
|
|
182
|
+
expect(batches.length,)
|
|
183
|
+
.toBe(1,);
|
|
184
|
+
},
|
|
185
|
+
},),
|
|
186
|
+
|
|
187
|
+
it({
|
|
188
|
+
name: 'an addition that would breach the cap flushes existing entries first',
|
|
189
|
+
fn: async () => {
|
|
190
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
191
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
192
|
+
|
|
193
|
+
buffer.add({ level: 'info', serialized: 'small', },);
|
|
194
|
+
buffer.add({ level: 'info', serialized: 'L'.repeat(40_000,), },);
|
|
195
|
+
|
|
196
|
+
// Two separate batches: the small record is never a batch-mate of the
|
|
197
|
+
// cap-breaching one.
|
|
198
|
+
expect(batches.length,)
|
|
199
|
+
.toBe(2,);
|
|
200
|
+
expect(batches[0],)
|
|
201
|
+
.toBe('small',);
|
|
202
|
+
},
|
|
203
|
+
},),
|
|
204
|
+
|
|
205
|
+
it({
|
|
206
|
+
name: 'the quiet-period deadline drains without any trigger call',
|
|
207
|
+
fn: async () => {
|
|
208
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
209
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
210
|
+
|
|
211
|
+
buffer.add({ level: 'debug', serialized: 'idle', },);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Comfortably past the buffer's 250 ms quiet-period deadline.
|
|
215
|
+
*/
|
|
216
|
+
const pastDeadlineMs = 400;
|
|
217
|
+
await wait(pastDeadlineMs,);
|
|
218
|
+
|
|
219
|
+
expect(batches.join('|',),)
|
|
220
|
+
.toBe('idle',);
|
|
221
|
+
},
|
|
222
|
+
timeout: 5_000,
|
|
223
|
+
},),
|
|
224
|
+
|
|
225
|
+
it({
|
|
226
|
+
name: 'drain on an empty buffer is a no-op',
|
|
227
|
+
fn: async () => {
|
|
228
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
229
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
230
|
+
|
|
231
|
+
buffer.drain();
|
|
232
|
+
buffer.drain();
|
|
233
|
+
expect(batches.length,)
|
|
234
|
+
.toBe(0,);
|
|
235
|
+
},
|
|
236
|
+
},),
|
|
237
|
+
|
|
238
|
+
it({
|
|
239
|
+
name: 'registers a pagehide listener that drains the buffer',
|
|
240
|
+
fn: async () => {
|
|
241
|
+
using listeners = installFakeGlobalListeners();
|
|
242
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
243
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
244
|
+
|
|
245
|
+
buffer.add({ level: 'info', serialized: 'leaving', },);
|
|
246
|
+
/**
|
|
247
|
+
* Captured pagehide handler; the buffer must have registered one.
|
|
248
|
+
*/
|
|
249
|
+
const onPagehide = listeners.handlers
|
|
250
|
+
.get('pagehide',);
|
|
251
|
+
expect((typeof onPagehide) === 'function',)
|
|
252
|
+
.toBe(true,);
|
|
253
|
+
|
|
254
|
+
onPagehide?.();
|
|
255
|
+
expect(batches.join('|',),)
|
|
256
|
+
.toBe('leaving',);
|
|
257
|
+
},
|
|
258
|
+
},),
|
|
259
|
+
|
|
260
|
+
it({
|
|
261
|
+
name: 'registers a visibilitychange listener that drains only when hidden',
|
|
262
|
+
fn: async () => {
|
|
263
|
+
using fakeDocument = installFakeDocument();
|
|
264
|
+
const { batches, onFlush, } = createCapturingFlush();
|
|
265
|
+
const buffer = createRecordBuffer({ onFlush, },);
|
|
266
|
+
|
|
267
|
+
buffer.add({ level: 'info', serialized: 'tabbed away', },);
|
|
268
|
+
/**
|
|
269
|
+
* Captured visibilitychange handler; the buffer must have registered one.
|
|
270
|
+
*/
|
|
271
|
+
const onVisibilityChange = fakeDocument.handlers
|
|
272
|
+
.get('visibilitychange',);
|
|
273
|
+
expect((typeof onVisibilityChange) === 'function',)
|
|
274
|
+
.toBe(true,);
|
|
275
|
+
|
|
276
|
+
// Still visible: the handler must not drain.
|
|
277
|
+
onVisibilityChange?.();
|
|
278
|
+
expect(batches.length,)
|
|
279
|
+
.toBe(0,);
|
|
280
|
+
|
|
281
|
+
fakeDocument.visibility.state = 'hidden';
|
|
282
|
+
onVisibilityChange?.();
|
|
283
|
+
expect(batches.join('|',),)
|
|
284
|
+
.toBe('tabbed away',);
|
|
285
|
+
},
|
|
286
|
+
},),
|
|
287
|
+
],
|
|
288
|
+
},);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-runtime default sessionStorage quota heuristics.
|
|
3
|
+
*
|
|
4
|
+
* The Web Storage API exposes no way to read the sessionStorage quota (unlike
|
|
5
|
+
* `navigator.storage.estimate()`, which reports the unrelated persistent-storage
|
|
6
|
+
* budget), so the sink caps its own footprint from a table of measured
|
|
7
|
+
* defaults. Each figure was fill-probed on a fresh store: values are written at
|
|
8
|
+
* a growing single key until a `QuotaExceededError`, binary-searching the
|
|
9
|
+
* largest that fits. Figures are UTF-16 code units (JS string length, counting
|
|
10
|
+
* key plus value) because that is what sessionStorage measures and what the
|
|
11
|
+
* sink compares `serialized.length` against.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
detectWebStorageRuntime,
|
|
18
|
+
type WebStorageRuntime,
|
|
19
|
+
} from './web-storage-runtime.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Measured default per-origin sessionStorage quotas, in UTF-16 code units, one
|
|
23
|
+
* bucket per detectable runtime:
|
|
24
|
+
*
|
|
25
|
+
* - `deno`: 10 MiB on Deno 2.9.
|
|
26
|
+
* - `node`: 5 MiB on Node 26.
|
|
27
|
+
* - `browser`: 5 MiB, measured identical on Chromium, Firefox, and WebKit under
|
|
28
|
+
* Playwright v1.61, so the three engines share one bucket and no fragile
|
|
29
|
+
* user-agent sniffing is needed to tell them apart.
|
|
30
|
+
*
|
|
31
|
+
* Bun 1.3 exposes no `sessionStorage`, so its bucket is uncapped: its sink
|
|
32
|
+
* never verifies and never reaches the cap. An unrecognized runtime is also
|
|
33
|
+
* uncapped so the caller relies on reactive eviction alone.
|
|
34
|
+
*/
|
|
35
|
+
const RUNTIME_QUOTA_CHARS: Record<WebStorageRuntime, number> = {
|
|
36
|
+
browser: 5_242_880,
|
|
37
|
+
bun: Number.POSITIVE_INFINITY,
|
|
38
|
+
deno: 10_485_760,
|
|
39
|
+
node: 5_242_880,
|
|
40
|
+
unknown: Number.POSITIVE_INFINITY,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detects the current runtime's default sessionStorage quota in UTF-16 code
|
|
45
|
+
* units, or `Number.POSITIVE_INFINITY` when the runtime is unrecognized so the
|
|
46
|
+
* caller leaves its footprint uncapped and relies on reactive eviction alone.
|
|
47
|
+
*
|
|
48
|
+
* @returns Total quota in code units, or `Number.POSITIVE_INFINITY` if unknown.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const capChars = detectSessionStorageQuotaChars() / 2; // half the total
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function detectSessionStorageQuotaChars(): number {
|
|
56
|
+
return RUNTIME_QUOTA_CHARS[detectWebStorageRuntime()];
|
|
57
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import {
|
|
7
|
+
_detectSessionStorageQuotaChars as detectSessionStorageQuotaChars,
|
|
8
|
+
} from '@monochromatic-dev/module-logger';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Temporarily sets `globalThis` keys to the supplied values, restoring each to
|
|
12
|
+
* its prior value (or deleting keys that were absent) when the returned guard
|
|
13
|
+
* leaves `using` scope, so a runtime-detection test can impersonate Deno, Bun,
|
|
14
|
+
* or a browser without leaking the fake globals into later tests.
|
|
15
|
+
*
|
|
16
|
+
* @param overrides - Global keys to install for the duration of the scope.
|
|
17
|
+
*
|
|
18
|
+
* @returns Disposable that restores the original globals on exit.
|
|
19
|
+
*/
|
|
20
|
+
function withGlobalOverrides(overrides: Record<string, unknown>,): Disposable {
|
|
21
|
+
const host = globalThis as unknown as Record<string, unknown>;
|
|
22
|
+
const saved = Object.entries(overrides,)
|
|
23
|
+
.map(function captureAndSet([key, value,],) {
|
|
24
|
+
const had = key in host;
|
|
25
|
+
const prior = host[key];
|
|
26
|
+
host[key] = value;
|
|
27
|
+
return {
|
|
28
|
+
key,
|
|
29
|
+
had,
|
|
30
|
+
prior,
|
|
31
|
+
};
|
|
32
|
+
},);
|
|
33
|
+
return {
|
|
34
|
+
[Symbol.dispose](): void {
|
|
35
|
+
for (const { key, had, prior, } of saved) {
|
|
36
|
+
if (had)
|
|
37
|
+
host[key] = prior;
|
|
38
|
+
else
|
|
39
|
+
// `delete host[key]` (dynamic key) is banned; this removes the key so
|
|
40
|
+
// an absent-before global (e.g. `Deno`) does not leak into later tests.
|
|
41
|
+
Reflect.deleteProperty(host, key,);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await describe({
|
|
48
|
+
name: detectSessionStorageQuotaChars.name,
|
|
49
|
+
// Serial because every test mutates process-global runtime markers.
|
|
50
|
+
concurrency: 1,
|
|
51
|
+
children: [
|
|
52
|
+
it({
|
|
53
|
+
name: 'reads node web storage as 5 MiB',
|
|
54
|
+
fn: async () => {
|
|
55
|
+
expect(detectSessionStorageQuotaChars(),)
|
|
56
|
+
.toBe(5_242_880,);
|
|
57
|
+
},
|
|
58
|
+
},),
|
|
59
|
+
|
|
60
|
+
it({
|
|
61
|
+
name: 'reads Deno web storage as 10 MiB',
|
|
62
|
+
fn: async () => {
|
|
63
|
+
using _override = withGlobalOverrides({ Deno: {}, },);
|
|
64
|
+
expect(detectSessionStorageQuotaChars(),)
|
|
65
|
+
.toBe(10_485_760,);
|
|
66
|
+
},
|
|
67
|
+
},),
|
|
68
|
+
|
|
69
|
+
it({
|
|
70
|
+
name: 'leaves Bun uncapped since it exposes no sessionStorage',
|
|
71
|
+
fn: async () => {
|
|
72
|
+
using _override = withGlobalOverrides({ Bun: {}, },);
|
|
73
|
+
expect(detectSessionStorageQuotaChars(),)
|
|
74
|
+
.toBe(Number.POSITIVE_INFINITY,);
|
|
75
|
+
},
|
|
76
|
+
},),
|
|
77
|
+
|
|
78
|
+
it({
|
|
79
|
+
name: 'reads a browser engine as 5 MiB',
|
|
80
|
+
fn: async () => {
|
|
81
|
+
// No node markers, but a DOM: the browser bucket, shared by Chromium,
|
|
82
|
+
// Firefox, and WebKit.
|
|
83
|
+
using _override = withGlobalOverrides({ process: undefined, document: {}, },);
|
|
84
|
+
expect(detectSessionStorageQuotaChars(),)
|
|
85
|
+
.toBe(5_242_880,);
|
|
86
|
+
},
|
|
87
|
+
},),
|
|
88
|
+
|
|
89
|
+
it({
|
|
90
|
+
name: 'leaves an unrecognized runtime uncapped',
|
|
91
|
+
fn: async () => {
|
|
92
|
+
using _override = withGlobalOverrides({ process: undefined, },);
|
|
93
|
+
expect(detectSessionStorageQuotaChars(),)
|
|
94
|
+
.toBe(Number.POSITIVE_INFINITY,);
|
|
95
|
+
},
|
|
96
|
+
},),
|
|
97
|
+
],
|
|
98
|
+
},);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { reportLoggerInternalError, } from '../error-format.ts';
|
|
2
|
+
import { detectSessionStorageQuotaChars, } from './session-storage-quota.ts';
|
|
3
|
+
import { isQuotaExceededError, } from './web-storage-quota-error.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prefix for sessionStorage keys to namespace log entries.
|
|
7
|
+
*/
|
|
8
|
+
const STORAGE_KEY_PREFIX = 'monochromatic.log';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds the namespaced sessionStorage key for a log entry at `index`.
|
|
12
|
+
*
|
|
13
|
+
* @param index - Zero-based slot number of an entry.
|
|
14
|
+
*
|
|
15
|
+
* @returns Prefixed key such as `monochromatic.log.3`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* storageKey(3); // 'monochromatic.log.3'
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
function storageKey(index: number,): string {
|
|
23
|
+
return `${STORAGE_KEY_PREFIX}.${index}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the persistence engine behind the sessionStorage sink: each `persist`
|
|
28
|
+
* lands one already-serialized batch under a counter-incremented key, with
|
|
29
|
+
* proactive and reactive quota eviction. The counter lives in this instance's
|
|
30
|
+
* closure (no module-global state), so independent sinks and tests never share
|
|
31
|
+
* keys or need a reset hook.
|
|
32
|
+
*
|
|
33
|
+
* The engine caps its own footprint at half the runtime's sessionStorage
|
|
34
|
+
* quota, proactively dropping its oldest entries, and reactively drops them
|
|
35
|
+
* again if the real store overflows; see {@link createSessionStorageStore.persist}.
|
|
36
|
+
*
|
|
37
|
+
* @returns Engine exposing `persist` for one batch value per call.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const store = createSessionStorageStore();
|
|
42
|
+
* store.persist('{"level":"info","message":"hi","timestamp":0}');
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function createSessionStorageStore(): { readonly persist: (batch: string,) => void; } {
|
|
46
|
+
/**
|
|
47
|
+
* Instance-local write cursor, eviction watermark, and footprint tally.
|
|
48
|
+
* `lineCounter` is the next slot to write and advances only when a `setItem`
|
|
49
|
+
* actually lands, so this engine's present entries occupy the contiguous
|
|
50
|
+
* range `[oldestIndex, lineCounter)`. `oldestIndex` is the lowest slot the
|
|
51
|
+
* engine still owns; eviction removes that entry and climbs `oldestIndex`
|
|
52
|
+
* toward `lineCounter`, so `oldestIndex < lineCounter` doubles as the "a
|
|
53
|
+
* prior write succeeded and an owned entry remains" guard that keeps
|
|
54
|
+
* eviction from ever touching another origin consumer's keys. `usedChars`
|
|
55
|
+
* tracks the code units this engine currently occupies so the half-quota cap
|
|
56
|
+
* needs no re-summing. `reportedFailure` gates the give-up diagnostic to
|
|
57
|
+
* once per failure episode: a persistently full store (another writer owning
|
|
58
|
+
* the space) would otherwise emit one `console.warn` per batch, so the flag
|
|
59
|
+
* stays set until a write next lands, which re-arms a single report for the
|
|
60
|
+
* next episode.
|
|
61
|
+
*/
|
|
62
|
+
const state: {
|
|
63
|
+
lineCounter: number;
|
|
64
|
+
oldestIndex: number;
|
|
65
|
+
usedChars: number;
|
|
66
|
+
reportedFailure: boolean;
|
|
67
|
+
} = {
|
|
68
|
+
lineCounter: 0,
|
|
69
|
+
oldestIndex: 0,
|
|
70
|
+
usedChars: 0,
|
|
71
|
+
reportedFailure: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Half the detected runtime sessionStorage quota, in UTF-16 code units, or
|
|
76
|
+
* `Number.POSITIVE_INFINITY` on an unrecognized runtime. The engine keeps
|
|
77
|
+
* its own footprint at or below this so the logger never claims more than
|
|
78
|
+
* half the store, leaving the rest for the host application. An infinite cap
|
|
79
|
+
* disables the proactive check, leaving only reactive quota-error eviction.
|
|
80
|
+
*/
|
|
81
|
+
const capChars = detectSessionStorageQuotaChars() / 2;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Removes this engine's oldest still-present entry, advancing the watermark
|
|
85
|
+
* and subtracting the reclaimed entry's code units from the running
|
|
86
|
+
* footprint. Reading the value back before removal keeps `usedChars` honest
|
|
87
|
+
* even if the entry drifted from what was written.
|
|
88
|
+
*/
|
|
89
|
+
function evictOldest(): void {
|
|
90
|
+
/**
|
|
91
|
+
* Key of the oldest owned entry, removed to reclaim its slot and its space.
|
|
92
|
+
*/
|
|
93
|
+
const key = storageKey(state.oldestIndex,);
|
|
94
|
+
/**
|
|
95
|
+
* Value being evicted, read back so its length can leave the footprint tally.
|
|
96
|
+
*/
|
|
97
|
+
const evicted = globalThis.sessionStorage
|
|
98
|
+
.getItem(key,);
|
|
99
|
+
globalThis.sessionStorage
|
|
100
|
+
.removeItem(key,);
|
|
101
|
+
state.oldestIndex++;
|
|
102
|
+
if (evicted !== null)
|
|
103
|
+
state.usedChars = Math.max(
|
|
104
|
+
0,
|
|
105
|
+
state.usedChars - evicted.length,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Persists one serialized batch to sessionStorage under a
|
|
111
|
+
* counter-incremented key.
|
|
112
|
+
*
|
|
113
|
+
* First it proactively drops its own oldest entries so its footprint stays
|
|
114
|
+
* at or below half the runtime's sessionStorage quota, leaving the rest for
|
|
115
|
+
* the host application. It then writes, and on a quota overflow (the store
|
|
116
|
+
* being fuller than the cap accounts for), and only while an owned entry
|
|
117
|
+
* remains (so the reclaimed keys are its own, never another origin
|
|
118
|
+
* consumer's), it drops its oldest still-present entry and retries until the
|
|
119
|
+
* batch fits or nothing of its own remains to drop. A batch larger than the
|
|
120
|
+
* whole quota therefore evicts every owned entry, then reports and gives up
|
|
121
|
+
* rather than looping forever. A non-quota failure is reported without any
|
|
122
|
+
* eviction. The sink only persists after verification, so no availability
|
|
123
|
+
* guard is needed here.
|
|
124
|
+
*
|
|
125
|
+
* @param batch - Serialized JSONL batch to persist.
|
|
126
|
+
*/
|
|
127
|
+
function persist(batch: string,): void {
|
|
128
|
+
/**
|
|
129
|
+
* Code units this batch adds; the key's length is left out as a negligible near-constant.
|
|
130
|
+
*/
|
|
131
|
+
const batchChars = batch.length;
|
|
132
|
+
|
|
133
|
+
// Proactively reclaim space so the engine's own footprint stays under the
|
|
134
|
+
// half-quota cap, dropping oldest-first while an owned entry remains. An
|
|
135
|
+
// infinite cap (unrecognized runtime) makes the guard always false.
|
|
136
|
+
while ((state.oldestIndex < state.lineCounter) && ((state.usedChars + batchChars) > capChars)) {
|
|
137
|
+
evictOldest();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write-attempt bound: one try for each entry still available to evict,
|
|
142
|
+
* followed by one final try after every owned entry has been removed.
|
|
143
|
+
*/
|
|
144
|
+
const maxWriteAttempts = (state.lineCounter - state.oldestIndex) + 1;
|
|
145
|
+
for (let writeAttempt = 0; writeAttempt < maxWriteAttempts; writeAttempt++) {
|
|
146
|
+
try {
|
|
147
|
+
globalThis.sessionStorage
|
|
148
|
+
.setItem(
|
|
149
|
+
storageKey(state.lineCounter,),
|
|
150
|
+
batch,
|
|
151
|
+
);
|
|
152
|
+
state.lineCounter++;
|
|
153
|
+
state.usedChars += batchChars;
|
|
154
|
+
// A landed write re-arms a single give-up report for the next episode.
|
|
155
|
+
state.reportedFailure = false;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
catch (error: unknown) {
|
|
159
|
+
if (isQuotaExceededError(error,) && (state.oldestIndex < state.lineCounter)) {
|
|
160
|
+
evictOldest();
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// Report once per failure episode, not once per unwritable batch, so a
|
|
164
|
+
// persistently full store does not flood the console every flush.
|
|
165
|
+
if (!state.reportedFailure) {
|
|
166
|
+
reportLoggerInternalError({
|
|
167
|
+
context: 'sessionStorage sink record write failed (repeats suppressed until a write next succeeds)',
|
|
168
|
+
error,
|
|
169
|
+
},);
|
|
170
|
+
state.reportedFailure = true;
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { persist, };
|
|
178
|
+
}
|