@kar-mi/spirit-vale-tools-logging 0.9.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/dist/index.js ADDED
@@ -0,0 +1,984 @@
1
+ // @bun
2
+ // src/logger.ts
3
+ import { mkdir, open, readFile, rename, stat, writeFile } from "fs/promises";
4
+ import { Buffer as Buffer2 } from "buffer";
5
+ import path2 from "path";
6
+
7
+ // src/paths.ts
8
+ import path from "path";
9
+ function defaultLogDirectory(workingDirectory = process.cwd()) {
10
+ return path.resolve(workingDirectory, "logs");
11
+ }
12
+ function streamCategoryDirectory(stream, logDirectory = defaultLogDirectory()) {
13
+ return path.join(logDirectory, stream);
14
+ }
15
+ function streamSessionPath(stream, sessionId, logDirectory = defaultLogDirectory()) {
16
+ return path.join(streamCategoryDirectory(stream, logDirectory), `${sessionId}.jsonl`);
17
+ }
18
+ function currentStreamPointerPath(stream, logDirectory = defaultLogDirectory()) {
19
+ return path.join(logDirectory, "current", `${stream}.json`);
20
+ }
21
+
22
+ // src/predicates.ts
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function isMissing(error) {
27
+ return isRecord(error) && error["code"] === "ENOENT";
28
+ }
29
+ function decimal(value) {
30
+ return typeof value === "string" && /^-?\d+$/.test(value);
31
+ }
32
+ function nullableString(value) {
33
+ return value === null || typeof value === "string";
34
+ }
35
+
36
+ // src/record-codec.ts
37
+ var V2_SEQUENCE_KEY = "seq";
38
+ var V2_TIMESTAMP_KEY = "at";
39
+ function encodeLogRecord(sequence, atMs, type, data) {
40
+ return JSON.stringify({
41
+ [V2_SEQUENCE_KEY]: sequence,
42
+ [V2_TIMESTAMP_KEY]: atMs,
43
+ type,
44
+ data
45
+ });
46
+ }
47
+ function encodeLogStreamHeader(header) {
48
+ return JSON.stringify(header);
49
+ }
50
+ function isLogStreamHeader(value) {
51
+ return isRecord(value) && value["schemaVersion"] === 2 && isNonEmptyString(value["sessionId"]);
52
+ }
53
+ function parseLogStreamHeader(value) {
54
+ if (!isLogStreamHeader(value))
55
+ return;
56
+ const candidate = value;
57
+ if (!isLogStream(candidate["stream"]) || !isNonEmptyString(candidate["producer"]) || !isIsoDate(candidate["startedAt"]))
58
+ return;
59
+ return value;
60
+ }
61
+ function parseLogRecord(value, header) {
62
+ if (!isRecord(value))
63
+ return;
64
+ if (value["schemaVersion"] === 1)
65
+ return parseV1(value);
66
+ if (isLogStreamHeader(value))
67
+ return;
68
+ return parseV2(value, header);
69
+ }
70
+ function parseV1(value) {
71
+ if (!isNonEmptyString(value["sessionId"]) || !isSequence(value["sequence"]))
72
+ return;
73
+ if (!isIsoDate(value["recordedAt"]) || !isNonEmptyString(value["source"]) || !isNonEmptyString(value["type"]))
74
+ return;
75
+ if (!isRecord(value["data"]))
76
+ return;
77
+ return value;
78
+ }
79
+ function parseV2(value, header) {
80
+ const sequence = value[V2_SEQUENCE_KEY];
81
+ const at = value[V2_TIMESTAMP_KEY];
82
+ if (!isSequence(sequence) || typeof at !== "number" || !Number.isFinite(at))
83
+ return;
84
+ if (!isNonEmptyString(value["type"]) || !isRecord(value["data"]))
85
+ return;
86
+ return {
87
+ schemaVersion: 2,
88
+ sessionId: header?.sessionId ?? "",
89
+ sequence,
90
+ recordedAt: new Date(at).toISOString(),
91
+ source: header?.producer ?? "",
92
+ type: value["type"],
93
+ data: value["data"]
94
+ };
95
+ }
96
+ function isSequence(value) {
97
+ return Number.isSafeInteger(value) && value >= 1;
98
+ }
99
+ function isNonEmptyString(value) {
100
+ return typeof value === "string" && value.length > 0;
101
+ }
102
+ function isIsoDate(value) {
103
+ return isNonEmptyString(value) && Number.isFinite(Date.parse(value));
104
+ }
105
+ function isLogStream(value) {
106
+ return value === "capture" || value === "combat" || value === "rewards" || value === "other";
107
+ }
108
+
109
+ // src/combat-sanitizer.ts
110
+ var IDENTITY_KEYS = new Set(["kind", "operation", "tick", "actorId", "displayName", "archetype", "ownerConnectionId", "uid"]);
111
+ var COMBAT_KEYS = new Set(["kind", "operation", "tick", "actorId", "mobId", "displayName", "value", "team", "sourceId", "sourceLabel", "recoveryStyle", "hitResult", "duplicatesDamageEvent", "critical", "targetId", "statusId", "level", "action", "phase", "skillId", "stacks", "rpc", "remainingSeconds"]);
112
+ function sanitizeCombatData(type, data) {
113
+ if (type === "combat.lifecycle" || type === "capture.lifecycle")
114
+ return pick(data, new Set(["state"]));
115
+ if (type === "combat.actorIdentity")
116
+ return pick(data, IDENTITY_KEYS);
117
+ if (type === "combat.event")
118
+ return pick(data, COMBAT_KEYS);
119
+ return;
120
+ }
121
+ function pick(data, keys) {
122
+ const result = {};
123
+ for (const key of keys) {
124
+ const value = data[key];
125
+ if (value !== undefined && (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null))
126
+ result[key] = value;
127
+ }
128
+ return result;
129
+ }
130
+
131
+ // src/logger.ts
132
+ var DEFAULT_BATCH_BYTES = 256 * 1024;
133
+ var DEFAULT_FLUSH_INTERVAL_MS = 50;
134
+ var DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
135
+
136
+ class JsonLinesLogger {
137
+ path;
138
+ sessionId;
139
+ source;
140
+ options;
141
+ sequence = 0;
142
+ lines = [];
143
+ currentBytes = 0;
144
+ bufferedBytes = 0;
145
+ queuedBatches = 0;
146
+ droppedRecords = 0;
147
+ timer;
148
+ tail = Promise.resolve();
149
+ firstFailure;
150
+ reportedFailure = false;
151
+ overflowing = false;
152
+ closed = false;
153
+ headerWritten = false;
154
+ handle;
155
+ batchBytes;
156
+ flushIntervalMs;
157
+ maxBufferedBytes;
158
+ constructor(path3, sessionId, source, options = { stream: "other" }) {
159
+ this.path = path3;
160
+ this.sessionId = sessionId;
161
+ this.source = source;
162
+ this.options = options;
163
+ this.batchBytes = options.batchBytes ?? DEFAULT_BATCH_BYTES;
164
+ this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
165
+ this.maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
166
+ }
167
+ log(type, data) {
168
+ const safeData = this.options.stream === "combat" ? sanitizeCombatData(type, data) : data;
169
+ if (!safeData)
170
+ return;
171
+ if (this.closed) {
172
+ this.droppedRecords += 1;
173
+ return;
174
+ }
175
+ this.ensureHeader();
176
+ const line = `${encodeLogRecord(++this.sequence, Date.now(), type, safeData)}
177
+ `;
178
+ const bytes = Buffer2.byteLength(line);
179
+ if (this.bufferedBytes + bytes > this.maxBufferedBytes) {
180
+ this.droppedRecords += 1;
181
+ if (!this.overflowing) {
182
+ this.overflowing = true;
183
+ this.notify(new Error(`log buffer exceeded ${this.maxBufferedBytes} bytes; records are being dropped`));
184
+ }
185
+ return;
186
+ }
187
+ this.overflowing = false;
188
+ this.lines.push(line);
189
+ this.currentBytes += bytes;
190
+ this.bufferedBytes += bytes;
191
+ if (this.currentBytes >= this.batchBytes)
192
+ this.enqueueCurrent();
193
+ else
194
+ this.scheduleFlush();
195
+ }
196
+ async flush() {
197
+ this.enqueueCurrent();
198
+ await this.tail;
199
+ if (this.firstFailure)
200
+ throw this.firstFailure;
201
+ }
202
+ async close() {
203
+ if (this.closed) {
204
+ await this.tail;
205
+ if (this.firstFailure)
206
+ throw this.firstFailure;
207
+ return;
208
+ }
209
+ this.closed = true;
210
+ this.enqueueCurrent();
211
+ await this.tail;
212
+ const handle = this.handle;
213
+ this.handle = undefined;
214
+ if (handle)
215
+ await handle.then((open2) => open2.close(), () => {
216
+ return;
217
+ });
218
+ if (this.firstFailure)
219
+ throw this.firstFailure;
220
+ }
221
+ stats() {
222
+ return {
223
+ bufferedBytes: this.bufferedBytes,
224
+ queuedBatches: this.queuedBatches,
225
+ failed: this.firstFailure !== undefined,
226
+ droppedRecords: this.droppedRecords
227
+ };
228
+ }
229
+ ensureHeader() {
230
+ if (this.headerWritten)
231
+ return;
232
+ this.headerWritten = true;
233
+ const header = {
234
+ schemaVersion: 2,
235
+ stream: this.options.stream,
236
+ sessionId: this.sessionId,
237
+ producer: this.source,
238
+ startedAt: new Date().toISOString()
239
+ };
240
+ const line = `${encodeLogStreamHeader(header)}
241
+ `;
242
+ const bytes = Buffer2.byteLength(line);
243
+ this.lines.push(line);
244
+ this.currentBytes += bytes;
245
+ this.bufferedBytes += bytes;
246
+ }
247
+ scheduleFlush() {
248
+ if (this.timer)
249
+ return;
250
+ this.timer = setTimeout(() => {
251
+ this.timer = undefined;
252
+ this.enqueueCurrent();
253
+ }, this.flushIntervalMs);
254
+ }
255
+ enqueueCurrent() {
256
+ if (this.timer)
257
+ clearTimeout(this.timer);
258
+ this.timer = undefined;
259
+ if (this.lines.length === 0)
260
+ return;
261
+ const text = this.lines.join("");
262
+ const bytes = this.currentBytes;
263
+ this.lines = [];
264
+ this.currentBytes = 0;
265
+ this.queuedBatches += 1;
266
+ this.tail = this.tail.then(() => this.write(text)).catch((error) => {
267
+ const failure = toError(error);
268
+ this.firstFailure ??= failure;
269
+ if (!this.reportedFailure) {
270
+ this.reportedFailure = true;
271
+ this.notify(failure);
272
+ }
273
+ }).finally(() => {
274
+ this.bufferedBytes = Math.max(0, this.bufferedBytes - bytes);
275
+ this.queuedBatches = Math.max(0, this.queuedBatches - 1);
276
+ });
277
+ }
278
+ async write(text) {
279
+ if (this.options.append) {
280
+ await this.options.append(this.path, text, "utf8");
281
+ return;
282
+ }
283
+ this.handle ??= open(this.path, "a");
284
+ let handle;
285
+ try {
286
+ handle = await this.handle;
287
+ } catch (error) {
288
+ this.handle = undefined;
289
+ throw error;
290
+ }
291
+ await handle.appendFile(text, "utf8");
292
+ }
293
+ notify(error) {
294
+ try {
295
+ this.options.onWriteError?.({ stream: this.options.stream, path: this.path, error });
296
+ } catch {}
297
+ }
298
+ }
299
+ async function ensureDirectory(directory) {
300
+ try {
301
+ if ((await stat(directory)).isDirectory())
302
+ return;
303
+ } catch {}
304
+ try {
305
+ await mkdir(directory, { recursive: true });
306
+ } catch (error) {
307
+ if (error.code !== "EEXIST")
308
+ throw error;
309
+ }
310
+ }
311
+ async function createLogSession(options) {
312
+ const logDirectory = options.logDirectory ?? defaultLogDirectory();
313
+ const id = createSessionId();
314
+ const createdAt = new Date().toISOString();
315
+ const streams = [...new Set(options.streams)];
316
+ if (streams.length === 0)
317
+ throw new Error("a log session requires at least one stream");
318
+ const activate = options.activate ?? true;
319
+ const loggers = new Map;
320
+ for (const stream of streams) {
321
+ const override = options.outputPaths?.[stream];
322
+ const streamPath = override ?? streamSessionPath(stream, id, logDirectory);
323
+ await ensureDirectory(path2.dirname(streamPath));
324
+ await writeFile(streamPath, "", override ? undefined : { flag: "wx" });
325
+ loggers.set(stream, new JsonLinesLogger(streamPath, id, options.producer, {
326
+ stream,
327
+ onWriteError: options.onWriteError,
328
+ ...tuning(options)
329
+ }));
330
+ if (!override && activate) {
331
+ const pointer = {
332
+ schemaVersion: 1,
333
+ stream,
334
+ sessionId: id,
335
+ startedAt: createdAt,
336
+ relativePath: path2.relative(logDirectory, streamPath)
337
+ };
338
+ await writeAtomicJson(currentStreamPointerPath(stream, logDirectory), pointer);
339
+ }
340
+ }
341
+ return {
342
+ id,
343
+ logger(stream) {
344
+ const logger = loggers.get(stream);
345
+ if (!logger)
346
+ throw new Error(`stream ${stream} is not part of session ${id}`);
347
+ return logger;
348
+ },
349
+ async flush() {
350
+ await settleAll([...loggers.values()].map((logger) => logger.flush()));
351
+ },
352
+ async close() {
353
+ await settleAll([...loggers.values()].map((logger) => logger.close()));
354
+ }
355
+ };
356
+ }
357
+ async function writeCurrentLogStreamPointer(pointer, logDirectory = defaultLogDirectory()) {
358
+ await writeAtomicJson(currentStreamPointerPath(pointer.stream, logDirectory), pointer);
359
+ }
360
+ async function activateLogSession(session, streams, logDirectory = defaultLogDirectory()) {
361
+ await session.flush?.();
362
+ const startedAt = new Date().toISOString();
363
+ for (const stream of streams) {
364
+ const streamPath = streamSessionPath(stream, session.id, logDirectory);
365
+ const pointer = {
366
+ schemaVersion: 1,
367
+ stream,
368
+ sessionId: session.id,
369
+ startedAt,
370
+ relativePath: path2.relative(logDirectory, streamPath)
371
+ };
372
+ await writeCurrentLogStreamPointer(pointer, logDirectory);
373
+ }
374
+ }
375
+ async function readCurrentLogStream(stream, logDirectory = defaultLogDirectory()) {
376
+ let value;
377
+ try {
378
+ value = JSON.parse(await readFile(currentStreamPointerPath(stream, logDirectory), "utf8"));
379
+ } catch (error) {
380
+ if (isMissing(error) || error instanceof SyntaxError)
381
+ return;
382
+ throw error;
383
+ }
384
+ if (!isRecord(value) || value["schemaVersion"] !== 1 || value["stream"] !== stream || !isNonEmptyString2(value["sessionId"]) || !isIsoDate2(value["startedAt"]) || !isNonEmptyString2(value["relativePath"]))
385
+ return;
386
+ const resolved = path2.resolve(logDirectory, value["relativePath"]);
387
+ const root = `${path2.resolve(logDirectory)}${path2.sep}`;
388
+ if (!resolved.startsWith(root))
389
+ return;
390
+ return { ...value, path: resolved };
391
+ }
392
+ function tuning(options) {
393
+ return {
394
+ ...options.batchBytes === undefined ? {} : { batchBytes: options.batchBytes },
395
+ ...options.flushIntervalMs === undefined ? {} : { flushIntervalMs: options.flushIntervalMs },
396
+ ...options.maxBufferedBytes === undefined ? {} : { maxBufferedBytes: options.maxBufferedBytes }
397
+ };
398
+ }
399
+ async function settleAll(operations) {
400
+ const results = await Promise.allSettled(operations);
401
+ const rejected = results.find((result) => result.status === "rejected");
402
+ if (rejected)
403
+ throw toError(rejected.reason);
404
+ }
405
+ function createSessionId() {
406
+ const timestamp = new Date().toISOString().replace(/[-:.]/g, "").replace("Z", "Z");
407
+ return `${timestamp}-${crypto.randomUUID().slice(0, 8)}`;
408
+ }
409
+ async function writeAtomicJson(target, value) {
410
+ await ensureDirectory(path2.dirname(target));
411
+ const temporary = `${target}.${crypto.randomUUID()}.tmp`;
412
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}
413
+ `, "utf8");
414
+ await rename(temporary, target);
415
+ }
416
+ function isNonEmptyString2(value) {
417
+ return typeof value === "string" && value.length > 0;
418
+ }
419
+ function isIsoDate2(value) {
420
+ return isNonEmptyString2(value) && Number.isFinite(Date.parse(value));
421
+ }
422
+ function toError(error) {
423
+ return error instanceof Error ? error : new Error(String(error));
424
+ }
425
+ // src/sessions.ts
426
+ import { lstat, open as open2, readFile as readFile2, readdir } from "fs/promises";
427
+ import path3 from "path";
428
+ var HEADER_PROBE_BYTES = 4096;
429
+ async function listLogSessions(stream, logDirectory = defaultLogDirectory(), limit = 25) {
430
+ if (!Number.isSafeInteger(limit) || limit < 0)
431
+ throw new RangeError("session limit must be a non-negative integer");
432
+ const categoryDirectory = streamCategoryDirectory(stream, logDirectory);
433
+ let entries;
434
+ try {
435
+ entries = await readdir(categoryDirectory, { withFileTypes: true });
436
+ } catch (error) {
437
+ if (isMissing(error))
438
+ return [];
439
+ throw error;
440
+ }
441
+ const current = await readCurrentPointer(stream, logDirectory);
442
+ const sessions = await Promise.all(entries.map(async (entry) => {
443
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".jsonl"))
444
+ return;
445
+ const sessionId = entry.name.slice(0, -".jsonl".length);
446
+ const filePath = path3.join(categoryDirectory, entry.name);
447
+ try {
448
+ const info = await lstat(filePath);
449
+ if (!info.isFile() || info.isSymbolicLink())
450
+ return;
451
+ const header = await readHeaderLine(filePath);
452
+ if (header && (header.sessionId !== sessionId || header.stream !== stream))
453
+ return;
454
+ const createdAt = header?.startedAt ?? info.mtime.toISOString();
455
+ return {
456
+ id: sessionId,
457
+ createdAt,
458
+ path: filePath,
459
+ active: current?.sessionId === sessionId && current.path === path3.resolve(filePath)
460
+ };
461
+ } catch {
462
+ return;
463
+ }
464
+ }));
465
+ return sessions.filter((session) => session !== undefined).sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || right.id.localeCompare(left.id)).slice(0, limit);
466
+ }
467
+ async function readHeaderLine(filePath) {
468
+ let handle;
469
+ try {
470
+ handle = await open2(filePath, "r");
471
+ } catch {
472
+ return;
473
+ }
474
+ try {
475
+ const buffer = Buffer.alloc(HEADER_PROBE_BYTES);
476
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_PROBE_BYTES, 0);
477
+ const text = buffer.toString("utf8", 0, bytesRead);
478
+ const newline = text.indexOf(`
479
+ `);
480
+ const line = newline === -1 ? text : text.slice(0, newline);
481
+ if (!line.trim())
482
+ return;
483
+ return parseLogStreamHeader(JSON.parse(line));
484
+ } catch {
485
+ return;
486
+ } finally {
487
+ await handle.close();
488
+ }
489
+ }
490
+ async function readCurrentPointer(stream, logDirectory) {
491
+ try {
492
+ const value = JSON.parse(await readFile2(currentStreamPointerPath(stream, logDirectory), "utf8"));
493
+ if (!isRecord(value) || value["schemaVersion"] !== 1 || value["stream"] !== stream || typeof value["sessionId"] !== "string" || typeof value["startedAt"] !== "string" || !Number.isFinite(Date.parse(value["startedAt"])) || typeof value["relativePath"] !== "string")
494
+ return;
495
+ const resolved = path3.resolve(logDirectory, value["relativePath"]);
496
+ const root = `${path3.resolve(logDirectory)}${path3.sep}`;
497
+ if (!resolved.startsWith(root))
498
+ return;
499
+ return { ...value, path: resolved };
500
+ } catch {
501
+ return;
502
+ }
503
+ }
504
+ // src/jsonl-tail-reader.ts
505
+ import { open as open3, stat as stat2 } from "fs/promises";
506
+ var NEWLINE = 10;
507
+
508
+ class JsonlTailReader {
509
+ path;
510
+ readOffset;
511
+ partialBytes = 0;
512
+ pending = "";
513
+ decoder;
514
+ createDecoder;
515
+ maxReadBytes;
516
+ constructor(path4, options = {}) {
517
+ this.path = path4;
518
+ this.createDecoder = options.createDecoder ?? (() => new TextDecoder);
519
+ const startOffset = options.startOffset ?? 0;
520
+ if (!Number.isSafeInteger(startOffset) || startOffset < 0) {
521
+ throw new RangeError("startOffset must be a non-negative integer");
522
+ }
523
+ const maxReadBytes = options.maxReadBytes ?? Number.POSITIVE_INFINITY;
524
+ if (maxReadBytes < 1)
525
+ throw new RangeError("maxReadBytes must be at least 1");
526
+ this.maxReadBytes = maxReadBytes;
527
+ this.readOffset = startOffset;
528
+ }
529
+ get offset() {
530
+ return this.readOffset - this.partialBytes;
531
+ }
532
+ get bytePosition() {
533
+ return this.readOffset;
534
+ }
535
+ async read(limitBytes = Number.POSITIVE_INFINITY) {
536
+ let size;
537
+ try {
538
+ size = (await stat2(this.path)).size;
539
+ } catch (error) {
540
+ if (isMissing(error))
541
+ return { missing: true, reset: false, lines: [], size: this.offset, bytesRead: 0 };
542
+ throw error;
543
+ }
544
+ const reset = size < this.readOffset;
545
+ if (reset)
546
+ this.reset();
547
+ if (size === this.readOffset)
548
+ return { missing: false, reset, lines: [], size, bytesRead: 0 };
549
+ const length = Math.min(size - this.readOffset, this.maxReadBytes, limitBytes);
550
+ if (length < 1)
551
+ return { missing: false, reset, lines: [], size, bytesRead: 0 };
552
+ const bytes = Buffer.allocUnsafe(length);
553
+ const file = await open3(this.path, "r");
554
+ try {
555
+ const { bytesRead } = await file.read(bytes, 0, length, this.readOffset);
556
+ this.readOffset += bytesRead;
557
+ const chunk = bytes.subarray(0, bytesRead);
558
+ this.trackPartial(chunk);
559
+ return { missing: false, reset, lines: this.consume(chunk), size, bytesRead };
560
+ } finally {
561
+ await file.close();
562
+ }
563
+ }
564
+ reset() {
565
+ this.readOffset = 0;
566
+ this.partialBytes = 0;
567
+ this.pending = "";
568
+ this.decoder = undefined;
569
+ }
570
+ trackPartial(chunk) {
571
+ const lastNewline = chunk.lastIndexOf(NEWLINE);
572
+ this.partialBytes = lastNewline === -1 ? this.partialBytes + chunk.length : chunk.length - lastNewline - 1;
573
+ }
574
+ consume(bytes) {
575
+ this.decoder ??= this.createDecoder(bytes);
576
+ this.pending += this.decoder.decode(bytes, { stream: true });
577
+ const lines = this.pending.split(/\r?\n/);
578
+ this.pending = lines.pop() ?? "";
579
+ return lines;
580
+ }
581
+ }
582
+ // src/stream-source.ts
583
+ import { watch } from "fs";
584
+ import path4 from "path";
585
+ var DEFAULT_STREAM_DEBOUNCE_MS = 20;
586
+ var DEFAULT_STREAM_FALLBACK_POLL_MS = 1000;
587
+ var DEFAULT_STREAM_BATCH_BYTES = 1024 * 1024;
588
+
589
+ class LogStreamSource {
590
+ stream;
591
+ logDirectory;
592
+ pointerPath;
593
+ fallbackPollMs;
594
+ debounceMs;
595
+ persistent;
596
+ readerOptions;
597
+ subscribers = new Set;
598
+ current;
599
+ reader;
600
+ pointerLoaded = false;
601
+ pointerDirty = true;
602
+ pointerWatcher;
603
+ fileWatcher;
604
+ debounceTimer;
605
+ fallbackTimer;
606
+ draining;
607
+ waiting = 0;
608
+ closed = false;
609
+ constructor(options) {
610
+ this.stream = options.stream;
611
+ this.logDirectory = options.logDirectory ?? defaultLogDirectory();
612
+ this.pointerPath = currentStreamPointerPath(this.stream, this.logDirectory);
613
+ this.fallbackPollMs = options.fallbackPollMs ?? DEFAULT_STREAM_FALLBACK_POLL_MS;
614
+ this.debounceMs = options.debounceMs ?? DEFAULT_STREAM_DEBOUNCE_MS;
615
+ this.persistent = options.persistent ?? false;
616
+ this.readerOptions = options.readerOptions ?? {};
617
+ }
618
+ subscribe() {
619
+ const subscriber = new Subscriber(this);
620
+ subscriber.catchUpFrom = this.reader === undefined ? undefined : this.reader.bytePosition;
621
+ this.subscribers.add(subscriber);
622
+ this.start();
623
+ return subscriber;
624
+ }
625
+ release(subscriber) {
626
+ if (!this.subscribers.delete(subscriber))
627
+ return;
628
+ if (this.subscribers.size === 0)
629
+ this.dispose();
630
+ }
631
+ async drain() {
632
+ this.draining ??= this.runDrain().finally(() => {
633
+ this.draining = undefined;
634
+ });
635
+ await this.draining;
636
+ }
637
+ async forceDrain() {
638
+ this.pointerDirty = true;
639
+ await this.drain();
640
+ if (this.pointerDirty)
641
+ await this.drain();
642
+ }
643
+ start() {
644
+ if (this.closed || this.fallbackTimer)
645
+ return;
646
+ this.ensurePointerWatcher();
647
+ this.fallbackTimer = this.hold(setInterval(() => void this.forceDrain(), this.fallbackPollMs));
648
+ }
649
+ hold(timer) {
650
+ if (!this.persistent && this.waiting === 0)
651
+ timer.unref?.();
652
+ return timer;
653
+ }
654
+ awaitingChanged(delta) {
655
+ this.waiting += delta;
656
+ if (this.persistent || !this.fallbackTimer)
657
+ return;
658
+ if (this.waiting > 0)
659
+ this.fallbackTimer.ref?.();
660
+ else if (this.waiting === 0)
661
+ this.fallbackTimer.unref?.();
662
+ }
663
+ dispose() {
664
+ this.closed = true;
665
+ releaseSource(this);
666
+ if (this.fallbackTimer)
667
+ clearInterval(this.fallbackTimer);
668
+ if (this.debounceTimer)
669
+ clearTimeout(this.debounceTimer);
670
+ this.fallbackTimer = undefined;
671
+ this.debounceTimer = undefined;
672
+ this.pointerWatcher?.close();
673
+ this.fileWatcher?.close();
674
+ this.pointerWatcher = undefined;
675
+ this.fileWatcher = undefined;
676
+ }
677
+ schedule() {
678
+ if (this.closed || this.debounceTimer)
679
+ return;
680
+ this.debounceTimer = this.hold(setTimeout(() => {
681
+ this.debounceTimer = undefined;
682
+ this.drain();
683
+ }, this.debounceMs));
684
+ }
685
+ async runDrain() {
686
+ if (this.closed)
687
+ return;
688
+ const changedSession = await this.syncPointer();
689
+ if (!this.reader) {
690
+ if (changedSession) {
691
+ this.publish({ missing: true, reset: false, lines: [], size: 0, bytesRead: 0, changedSession, capped: false });
692
+ }
693
+ return;
694
+ }
695
+ const result = await this.reader.read();
696
+ const capped = result.bytesRead > 0 && this.reader.bytePosition < result.size;
697
+ if (!changedSession && !result.reset && !result.missing && result.lines.length === 0)
698
+ return;
699
+ this.publish({ ...result, changedSession, capped, ...this.current ? { current: this.current } : {} });
700
+ if (capped)
701
+ this.schedule();
702
+ }
703
+ async syncPointer() {
704
+ if (this.pointerLoaded && !this.pointerDirty)
705
+ return false;
706
+ this.pointerDirty = false;
707
+ this.pointerLoaded = true;
708
+ const pointer = await readCurrentLogStream(this.stream, this.logDirectory);
709
+ if (!pointer) {
710
+ if (!this.current)
711
+ return false;
712
+ this.current = undefined;
713
+ this.reader = undefined;
714
+ this.fileWatcher?.close();
715
+ this.fileWatcher = undefined;
716
+ return true;
717
+ }
718
+ if (pointer.sessionId === this.current?.sessionId)
719
+ return false;
720
+ this.current = { path: pointer.path, sessionId: pointer.sessionId };
721
+ this.reader = new JsonlTailReader(pointer.path, this.readerOptions);
722
+ for (const subscriber of this.subscribers)
723
+ subscriber.catchUpFrom = undefined;
724
+ this.watchActiveFile(pointer.path);
725
+ return true;
726
+ }
727
+ publish(read) {
728
+ for (const subscriber of this.subscribers)
729
+ subscriber.push(read);
730
+ }
731
+ ensurePointerWatcher() {
732
+ if (this.pointerWatcher || this.closed)
733
+ return;
734
+ const directory = path4.dirname(this.pointerPath);
735
+ const name = path4.basename(this.pointerPath);
736
+ this.pointerWatcher = this.tryWatch(directory, (filename) => {
737
+ if (filename === null || filename === name) {
738
+ this.pointerDirty = true;
739
+ this.schedule();
740
+ }
741
+ });
742
+ }
743
+ watchActiveFile(activePath) {
744
+ this.fileWatcher?.close();
745
+ this.fileWatcher = this.tryWatch(activePath, () => this.schedule());
746
+ }
747
+ tryWatch(target, onEvent) {
748
+ let watcher;
749
+ try {
750
+ watcher = watch(target, { persistent: this.persistent }, (_event, filename) => {
751
+ onEvent(typeof filename === "string" ? filename : null);
752
+ });
753
+ } catch (error) {
754
+ if (isMissing(error))
755
+ return;
756
+ throw error;
757
+ }
758
+ watcher.on("error", () => watcher.close());
759
+ return watcher;
760
+ }
761
+ async catchUp(subscriber) {
762
+ const target = subscriber.catchUpFrom;
763
+ if (target === undefined || !this.current)
764
+ return [];
765
+ const reader = new JsonlTailReader(this.current.path, this.readerOptions);
766
+ const reads = [];
767
+ while (reader.bytePosition < target) {
768
+ const result = await reader.read(target - reader.bytePosition);
769
+ if (result.missing || result.bytesRead === 0)
770
+ break;
771
+ reads.push({ ...result, changedSession: false, capped: false, current: this.current });
772
+ }
773
+ subscriber.catchUpFrom = undefined;
774
+ if (reads.length > 0)
775
+ reads[0].changedSession = true;
776
+ return reads;
777
+ }
778
+ emptyRead() {
779
+ return {
780
+ missing: this.current === undefined,
781
+ reset: false,
782
+ lines: [],
783
+ size: this.reader?.offset ?? 0,
784
+ bytesRead: 0,
785
+ changedSession: false,
786
+ capped: false,
787
+ ...this.current ? { current: this.current } : {}
788
+ };
789
+ }
790
+ get loaded() {
791
+ return this.pointerLoaded;
792
+ }
793
+ }
794
+
795
+ class Subscriber {
796
+ source;
797
+ catchUpFrom;
798
+ replayQueue = [];
799
+ pending;
800
+ waiter;
801
+ closed = false;
802
+ constructor(source) {
803
+ this.source = source;
804
+ }
805
+ push(read) {
806
+ if (this.closed)
807
+ return;
808
+ this.pending = this.pending ? mergeReads(this.pending, read) : read;
809
+ const waiter = this.waiter;
810
+ if (!waiter)
811
+ return;
812
+ this.waiter = undefined;
813
+ waiter(this.take());
814
+ }
815
+ async poll() {
816
+ if (this.closed)
817
+ return this.source.emptyRead();
818
+ const replayed = await this.replay();
819
+ if (replayed)
820
+ return replayed;
821
+ await this.source.forceDrain();
822
+ return this.take() ?? this.source.emptyRead();
823
+ }
824
+ async next() {
825
+ if (this.closed)
826
+ return this.source.emptyRead();
827
+ const replayed = await this.replay();
828
+ if (replayed)
829
+ return replayed;
830
+ if (!this.source.loaded)
831
+ await this.source.drain();
832
+ const taken = this.take();
833
+ if (taken)
834
+ return taken;
835
+ this.source.awaitingChanged(1);
836
+ try {
837
+ return await new Promise((resolve) => {
838
+ this.waiter = resolve;
839
+ });
840
+ } finally {
841
+ this.source.awaitingChanged(-1);
842
+ }
843
+ }
844
+ async* [Symbol.asyncIterator]() {
845
+ while (!this.closed)
846
+ yield await this.next();
847
+ }
848
+ close() {
849
+ if (this.closed)
850
+ return;
851
+ this.closed = true;
852
+ this.waiter?.(this.source.emptyRead());
853
+ this.waiter = undefined;
854
+ this.source.release(this);
855
+ }
856
+ async replay() {
857
+ if (this.catchUpFrom === undefined)
858
+ return;
859
+ if (this.replayQueue.length === 0)
860
+ this.replayQueue = await this.source.catchUp(this);
861
+ return this.replayQueue.shift();
862
+ }
863
+ take() {
864
+ const pending = this.pending;
865
+ this.pending = undefined;
866
+ return pending;
867
+ }
868
+ }
869
+ function mergeReads(previous, next) {
870
+ const changedSession = previous.changedSession || next.changedSession;
871
+ if (next.reset)
872
+ return { ...next, changedSession };
873
+ return {
874
+ ...next,
875
+ lines: previous.lines.concat(next.lines),
876
+ reset: previous.reset,
877
+ changedSession,
878
+ bytesRead: previous.bytesRead + next.bytesRead
879
+ };
880
+ }
881
+ var sources = new Map;
882
+ var keys = new WeakMap;
883
+ function subscribeToLogStream(options) {
884
+ const key = `${options.logDirectory ?? defaultLogDirectory()}\x00${options.stream}`;
885
+ let source = sources.get(key);
886
+ if (!source) {
887
+ source = new LogStreamSource(options);
888
+ sources.set(key, source);
889
+ keys.set(source, key);
890
+ }
891
+ return source.subscribe();
892
+ }
893
+ function releaseSource(source) {
894
+ const key = keys.get(source);
895
+ if (key !== undefined && sources.get(key) === source)
896
+ sources.delete(key);
897
+ }
898
+
899
+ // src/session-follower.ts
900
+ class LiveLogSessionFollower {
901
+ options;
902
+ sessionId;
903
+ follower;
904
+ subscription;
905
+ closed = false;
906
+ constructor(options) {
907
+ this.options = options;
908
+ }
909
+ async poll() {
910
+ return this.apply(await this.source().poll());
911
+ }
912
+ async next() {
913
+ return this.apply(await this.source().next());
914
+ }
915
+ async* [Symbol.asyncIterator]() {
916
+ this.closed = false;
917
+ while (!this.closed) {
918
+ const read = await this.source().next();
919
+ if (this.closed)
920
+ return;
921
+ yield this.apply(read);
922
+ }
923
+ }
924
+ close() {
925
+ this.closed = true;
926
+ this.subscription?.close();
927
+ this.subscription = undefined;
928
+ }
929
+ source() {
930
+ this.subscription ??= subscribeToLogStream({
931
+ stream: this.options.stream,
932
+ ...this.options.logDirectory === undefined ? {} : { logDirectory: this.options.logDirectory },
933
+ ...this.options.fallbackPollMs === undefined ? {} : { fallbackPollMs: this.options.fallbackPollMs },
934
+ ...this.options.debounceMs === undefined ? {} : { debounceMs: this.options.debounceMs },
935
+ ...this.options.persistent === undefined ? {} : { persistent: this.options.persistent },
936
+ ...this.options.readerOptions === undefined ? {} : { readerOptions: this.options.readerOptions }
937
+ });
938
+ return this.subscription;
939
+ }
940
+ apply(read) {
941
+ if (!read.current) {
942
+ const reset = this.follower !== undefined;
943
+ this.follower = undefined;
944
+ this.sessionId = undefined;
945
+ return this.options.noStreamBatch(reset);
946
+ }
947
+ const changedSession = read.current.sessionId !== this.sessionId;
948
+ if (changedSession) {
949
+ this.sessionId = read.current.sessionId;
950
+ this.follower = this.options.createFollower(read.current.path);
951
+ }
952
+ const batch = this.follower.consumeRead(read);
953
+ const merged = this.options.mergeSessionChange(batch, changedSession);
954
+ return { ...merged, path: read.current.path, sessionId: read.current.sessionId };
955
+ }
956
+ }
957
+ export {
958
+ DEFAULT_STREAM_BATCH_BYTES,
959
+ DEFAULT_STREAM_DEBOUNCE_MS,
960
+ DEFAULT_STREAM_FALLBACK_POLL_MS,
961
+ JsonLinesLogger,
962
+ JsonlTailReader,
963
+ LiveLogSessionFollower,
964
+ activateLogSession,
965
+ createLogSession,
966
+ currentStreamPointerPath,
967
+ decimal,
968
+ defaultLogDirectory,
969
+ encodeLogRecord,
970
+ encodeLogStreamHeader,
971
+ isLogStreamHeader,
972
+ isMissing,
973
+ isRecord,
974
+ listLogSessions,
975
+ nullableString,
976
+ parseLogRecord,
977
+ parseLogStreamHeader,
978
+ readCurrentLogStream,
979
+ sanitizeCombatData,
980
+ streamCategoryDirectory,
981
+ streamSessionPath,
982
+ subscribeToLogStream,
983
+ writeCurrentLogStreamPointer
984
+ };