logroller 2.0.2 → 2.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/dist/index.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! logroller v2.0.1 | MIT License */
1
+ /*! logroller v2.1.0 | MIT License */
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
@@ -24,13 +24,13 @@ const existsSafe = (p) => {
24
24
  }
25
25
  };
26
26
  /**
27
- * Compress `src` → `dst`, deleting `src` ONLY on verified success.
28
- * On failure removes the partial `dst` and reports via callback —
27
+ * Compress `src` → `dst` at gzip `level`, deleting `src` ONLY on verified
28
+ * success. On failure removes the partial `dst` and reports via callback —
29
29
  * logs degrade to uncompressed rather than losing data.
30
30
  */
31
- async function gzipMove(src, dst, onWarn) {
31
+ async function gzipMove(src, dst, onWarn, level = 6) {
32
32
  try {
33
- await pipeline(fs.createReadStream(src), zlib.createGzip({ level: 6 }), fs.createWriteStream(dst));
33
+ await pipeline(fs.createReadStream(src), zlib.createGzip({ level }), fs.createWriteStream(dst));
34
34
  await fs.promises.unlink(src);
35
35
  return true;
36
36
  }
@@ -43,21 +43,47 @@ async function gzipMove(src, dst, onWarn) {
43
43
  const endStream = (ws) => ws ? new Promise((resolve) => ws.end(() => resolve())) : Promise.resolve();
44
44
 
45
45
  const UNITS = {
46
- b: 1, k: 1024, kb: 1024,
47
- m: 1048576, mb: 1048576,
48
- g: 1073741824, gb: 1073741824,
46
+ b: 1,
47
+ k: 1024,
48
+ kb: 1024,
49
+ m: 1048576,
50
+ mb: 1048576,
51
+ g: 1073741824,
52
+ gb: 1073741824,
49
53
  };
50
- const FREQ_MULT = {
51
- ms: 1, s: 1000, m: 60_000, h: 3_600_000,
54
+ /**
55
+ * Every unit token the duration regex can capture MUST have an entry here —
56
+ * a captured-but-unmapped unit would otherwise surface as a NaN that
57
+ * detonates later (e.g. `RangeError: Invalid time value` from Intl),
58
+ * far away from the actual misconfiguration.
59
+ */
60
+ const DURATION_ALIASES = {
61
+ ms: 1,
62
+ s: 1_000,
63
+ m: 60_000,
64
+ h: 3_600_000,
65
+ hour: 3_600_000,
66
+ hours: 3_600_000,
52
67
  };
68
+ /**
69
+ * Parse `"<n><unit?>"` or a bare number into milliseconds.
70
+ * Bare numbers are interpreted in `defaultUnit`.
71
+ *
72
+ * Accepted unit spellings: ms, s, m, h, hour, hours (case-insensitive).
73
+ */
53
74
  function parseDuration(value, defaultUnit, fieldName) {
54
75
  if (typeof value === "number") {
55
- return Math.round(value * FREQ_MULT[defaultUnit]);
76
+ return Math.round(value * DURATION_ALIASES[defaultUnit]);
56
77
  }
57
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
78
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*(h(?:our)?s?|ms|s|m)?\s*$/i.exec(value);
58
79
  if (!m)
59
80
  throw new Error(`Invalid ${fieldName} "${value}"`);
60
- return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? defaultUnit).toLowerCase()]);
81
+ const unit = (m[2] ?? defaultUnit).toLowerCase();
82
+ const mult = DURATION_ALIASES[unit];
83
+ if (mult === undefined) {
84
+ throw new Error(`Invalid ${fieldName} unit "${m[2]}" in "${value}"`);
85
+ }
86
+ return Math.round(Number.parseFloat(m[1]) * mult);
61
87
  }
62
88
  function parseSize(v) {
63
89
  if (v === undefined || v === null || v === 0)
@@ -72,8 +98,11 @@ function parseSize(v) {
72
98
  function parseMaxFiles(v) {
73
99
  if (v === undefined || v === null || v === "" || v === 0)
74
100
  return null;
75
- if (typeof v === "number")
101
+ if (typeof v === "number") {
102
+ if (v < 0)
103
+ throw new Error(`Invalid maxFiles "${v}"`);
76
104
  return { count: v };
105
+ }
77
106
  const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
78
107
  if (m)
79
108
  return { days: Number.parseInt(m[1], 10) };
@@ -97,15 +126,23 @@ function parseFrequency(v) {
97
126
  throw new Error(`Invalid frequency "${v}"`);
98
127
  return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
99
128
  }
129
+ const FREQ_MULT = {
130
+ ms: 1,
131
+ s: 1_000,
132
+ m: 60_000,
133
+ h: 3_600_000,
134
+ };
135
+ /** Static virtual-clock shift. Accepts hours by default; h/hours/m/s/ms. */
100
136
  function parseClockOffset(v) {
101
137
  if (v === undefined || v === null || v === "")
102
138
  return 0;
103
139
  return parseDuration(v, "h", "addHours");
104
140
  }
141
+ /** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
105
142
  function parseClockStep(v) {
106
143
  if (v === undefined || v === null || v === "")
107
144
  return 0;
108
- return parseDuration(v, "h", "addHours");
145
+ return parseDuration(v, "h", "addHoursEveryMin");
109
146
  }
110
147
 
111
148
  /* Timezone-aware helpers built exclusively on Intl (zero deps). */
@@ -212,17 +249,22 @@ const emptyAudit = (prefix, ext, datePattern, tz) => ({
212
249
  });
213
250
  /**
214
251
  * A `Writable` that appends to dated log files, rolling segments on time
215
- * and/or size, optionally gzipping sealed segments, enforcing retention,
216
- * and resuming an unbroken series across restarts via disk scan + audit
217
- * manifest.
252
+ * and/or size, optionally gzipping sealed segments, enforcing retention
253
+ * (count, age, and total-byte budget), maintaining a stable "current"
254
+ * symlink for log collectors, running an awaited seal hook (upload
255
+ * pipeline), and resuming an unbroken series across restarts via disk
256
+ * scan + audit manifest.
218
257
  *
219
258
  * @example
220
259
  * ```ts
221
260
  * const log = createStream({
222
261
  * filename: "logs/app-%DATE%.log",
223
262
  * maxSize: "20m",
263
+ * maxTotalSize: "5g",
224
264
  * maxFiles: "90d",
225
265
  * zippedArchive: true,
266
+ * symlink: true,
267
+ * onSeal: async ({ gz }) => uploadToS3(gz),
226
268
  * });
227
269
  * log.write("hello\n"); // or: readable.pipe(log)
228
270
  * ```
@@ -237,6 +279,7 @@ class RotateFileStream extends Writable {
237
279
  maxFiles;
238
280
  freq;
239
281
  unrefTimers;
282
+ maxTotalSize;
240
283
  /* ---- filename-template pieces ---- */
241
284
  _tL;
242
285
  _tMid;
@@ -247,6 +290,10 @@ class RotateFileStream extends Writable {
247
290
  _auditEnabled;
248
291
  _auditPath;
249
292
  _bootId = randomUUID();
293
+ _onSeal;
294
+ _sealHookTimeoutMs;
295
+ _compressionLevel;
296
+ _symlinkPath = null;
250
297
  /* ---- virtual test clock ---- */
251
298
  _offsetMs;
252
299
  _stepMs;
@@ -277,11 +324,16 @@ class RotateFileStream extends Writable {
277
324
  this.zippedArchive = !!opts.zippedArchive;
278
325
  this.maxSize = parseSize(opts.maxSize ?? 0);
279
326
  this.maxFiles = parseMaxFiles(opts.maxFiles);
327
+ this.maxTotalSize = parseSize(opts.maxTotalSize ?? 0);
280
328
  this.freq = parseFrequency(opts.frequency);
281
329
  this.unrefTimers = !!opts.unrefTimers;
282
330
  this._offsetMs = parseClockOffset(opts.addHours);
283
331
  this._stepMs = parseClockStep(opts.addHoursEveryMin);
284
332
  this._bumpEvery = Math.max(50, opts.clockStepIntervalMs ?? BUMP_DEFAULT_MS);
333
+ // compression + seal hook
334
+ this._compressionLevel = Math.min(9, Math.max(0, opts.compressionLevel ?? 6));
335
+ this._onSeal = opts.onSeal;
336
+ this._sealHookTimeoutMs = Math.max(0, opts.sealHookTimeoutMs ?? 30_000);
285
337
  /* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
286
338
  stamp and the extension: prefix-STAMP.N.ext */
287
339
  const li = this.template.indexOf("%DATE%");
@@ -298,6 +350,12 @@ class RotateFileStream extends Writable {
298
350
  const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
299
351
  .replace(/^[-.]+|[-.]+$/g, "");
300
352
  this._auditPath = path.join(this.dir, opts.auditFile || `${stem}_audit.json`);
353
+ // stable "current" pointer target
354
+ this._symlinkPath = opts.symlink
355
+ ? path.join(this.dir, typeof opts.symlink === "string"
356
+ ? opts.symlink
357
+ : `${stem}-current.log`)
358
+ : null;
301
359
  fs.mkdirSync(this.dir, { recursive: true });
302
360
  this._curStamp = this._stampNow();
303
361
  this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
@@ -314,6 +372,7 @@ class RotateFileStream extends Writable {
314
372
  offsetMs: this._offsetMs,
315
373
  }));
316
374
  }
375
+ // warn when frequency is finer than the datePattern tokens
317
376
  process.nextTick(() => {
318
377
  const warn = (m) => this.emit("warn", new Error(m));
319
378
  if (this.freq.type === "interval") {
@@ -333,6 +392,7 @@ class RotateFileStream extends Writable {
333
392
  constructor so it deterministically precedes any write job: zombie
334
393
  demotion sees `_path === null`, gzip repair cannot race a reopen. */
335
394
  this._job(async () => {
395
+ // disk-driven, audit-independent (runs even with audit:false)
336
396
  await this._sealOrphanedPastStamps();
337
397
  if (this._auditEnabled) {
338
398
  this._demoteZombieActives();
@@ -341,8 +401,8 @@ class RotateFileStream extends Writable {
341
401
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
342
402
  this._saveAudit();
343
403
  }
344
- if (this.maxFiles)
345
- this._retire();
404
+ if (this.maxFiles || this.maxTotalSize > 0)
405
+ this._retire(); // budget policy also gates
346
406
  }).catch(() => { });
347
407
  this._armTimer();
348
408
  this._armBump();
@@ -383,6 +443,60 @@ class RotateFileStream extends Writable {
383
443
  advanceClock() {
384
444
  return this._job(() => this._applyBump());
385
445
  }
446
+ /**
447
+ * Every family file on disk, oldest stamp first.
448
+ * Disk is truth: untracked files are reported with state "untracked".
449
+ */
450
+ listSegments() {
451
+ const rx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
452
+ "(?:\\.(\\d+))?" + escRe(this._tExt) + "(?:\\.gz)?$");
453
+ const skip = this._symlinkPath && path.basename(this._symlinkPath);
454
+ const out = [];
455
+ let names = [];
456
+ try {
457
+ names = fs.readdirSync(this.dir);
458
+ }
459
+ catch {
460
+ return out;
461
+ }
462
+ for (const n of names) {
463
+ if (n === skip)
464
+ continue;
465
+ const m = rx.exec(n);
466
+ if (!m)
467
+ continue;
468
+ const rec = this._audit.files[n];
469
+ out.push({
470
+ file: path.join(this.dir, n),
471
+ stamp: m[1],
472
+ index: m[2] ? Number.parseInt(m[2], 10) : 0,
473
+ gzipped: n.endsWith(".gz"),
474
+ bytes: statSizeSafe(path.join(this.dir, n)),
475
+ state: rec?.state ?? "untracked",
476
+ openedAt: rec?.openedAt,
477
+ archivedAt: rec?.archivedAt,
478
+ });
479
+ }
480
+ out.sort((a, b) => a.stamp.localeCompare(b.stamp) || a.index - b.index);
481
+ return out;
482
+ }
483
+ /**
484
+ * Health-check snapshot for dashboards and support triage.
485
+ */
486
+ stats() {
487
+ const segs = this.listSegments();
488
+ return {
489
+ dir: this.dir,
490
+ currentStamp: this._curStamp,
491
+ activeFile: this._path,
492
+ activeIndex: this._index,
493
+ segments: segs.length,
494
+ archived: segs.filter((s) => s.gzipped).length,
495
+ totalBytes: segs.reduce((sum, s) => sum + s.bytes, 0),
496
+ clockOffsetMs: this._offsetMs,
497
+ symlink: this._symlinkPath,
498
+ };
499
+ }
386
500
  /* ==================================================================
387
501
  * Writable contract
388
502
  * ================================================================ */
@@ -515,6 +629,57 @@ class RotateFileStream extends Writable {
515
629
  }
516
630
  return idx;
517
631
  }
632
+ /**
633
+ * Atomically flip the "current" symlink to the active segment.
634
+ * tmp + rename so followers (`tail -f`, Filebeat) never observe a gap.
635
+ * Relative target keeps the link portable. Best-effort: filesystems
636
+ * denying symlinks warn once and disable the feature permanently.
637
+ */
638
+ _updateSymlink() {
639
+ const link = this._symlinkPath;
640
+ if (!link || !this._path)
641
+ return;
642
+ const tmp = `${link}.tmp`;
643
+ try {
644
+ try {
645
+ fs.unlinkSync(tmp);
646
+ }
647
+ catch { /* absent */ }
648
+ fs.symlinkSync(path.basename(this._path), tmp, "file");
649
+ fs.renameSync(tmp, link);
650
+ }
651
+ catch (e) {
652
+ this._symlinkPath = null;
653
+ this.emit("warn", new Error(`symlink unavailable ("${link}"): ${e.message} — continuing without it`));
654
+ }
655
+ }
656
+ /**
657
+ * Await the user's onSeal hook with a hard timeout. Result or
658
+ * failure degrades to `warn` — a wedged upload must never stall rotation.
659
+ * Called BEFORE retention so uploads finish before deletion.
660
+ */
661
+ async _runSealHook(raw, gz) {
662
+ const hook = this._onSeal;
663
+ if (!hook)
664
+ return;
665
+ let timer;
666
+ try {
667
+ await Promise.race([
668
+ Promise.resolve().then(() => hook({ raw, gz })),
669
+ new Promise((_, reject) => {
670
+ timer = setTimeout(() => reject(new Error(`onSeal hook timed out after ${this._sealHookTimeoutMs}ms ` +
671
+ `(${path.basename(gz ?? raw)})`)), this._sealHookTimeoutMs);
672
+ }),
673
+ ]);
674
+ }
675
+ catch (err) {
676
+ this.emit("warn", err);
677
+ }
678
+ finally {
679
+ if (timer)
680
+ clearTimeout(timer);
681
+ }
682
+ }
518
683
  async _ensureOpen() {
519
684
  if (this._ws)
520
685
  return;
@@ -524,6 +689,7 @@ class RotateFileStream extends Writable {
524
689
  ws.on("error", (e) => this.emit("error", e));
525
690
  this._ws = ws;
526
691
  this._path = p;
692
+ this._updateSymlink(); // flip pointer on every open
527
693
  if (this._auditEnabled) {
528
694
  const base = path.basename(p);
529
695
  // reopen = NEW incarnation. Never inherit lifecycle
@@ -566,9 +732,10 @@ class RotateFileStream extends Writable {
566
732
  rec.bytes = realBytes;
567
733
  this._saveAudit();
568
734
  }
569
- const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
735
+ const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e), this._compressionLevel);
570
736
  if (ok) {
571
737
  this._finishArchivedRecord(oldBase);
738
+ await this._runSealHook(oldPath, archive);
572
739
  }
573
740
  else {
574
741
  if (rec) {
@@ -577,6 +744,7 @@ class RotateFileStream extends Writable {
577
744
  }
578
745
  this._aevent("gzip-failed", { file: oldBase });
579
746
  archive = null;
747
+ await this._runSealHook(oldPath, null);
580
748
  }
581
749
  }
582
750
  else if (realBytes === 0) {
@@ -587,6 +755,7 @@ class RotateFileStream extends Writable {
587
755
  if (existsSafe(adoptedGz)) {
588
756
  this._finishArchivedRecord(oldBase);
589
757
  archive = adoptedGz;
758
+ await this._runSealHook(oldPath, adoptedGz);
590
759
  }
591
760
  else {
592
761
  try {
@@ -600,9 +769,13 @@ class RotateFileStream extends Writable {
600
769
  this._aevent("drop-empty", { file: oldBase });
601
770
  }
602
771
  }
603
- else if (rec) {
604
- rec.state = "sealed";
605
- rec.bytes = realBytes;
772
+ else if (realBytes > 0) {
773
+ // uncompressed seal (zippedArchive off): still notify sealers
774
+ if (rec) {
775
+ rec.state = "sealed";
776
+ rec.bytes = realBytes;
777
+ }
778
+ await this._runSealHook(oldPath, null);
606
779
  }
607
780
  }
608
781
  let stamp = this._stampNow();
@@ -929,7 +1102,8 @@ class RotateFileStream extends Writable {
929
1102
  const full = path.join(this.dir, name);
930
1103
  if (!existsSafe(full) || statSizeSafe(full) === 0)
931
1104
  continue;
932
- const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
1105
+ const gzName = `${full}.gz`;
1106
+ const ok = await gzipMove(full, gzName, (e) => this.emit("warn", e), this._compressionLevel);
933
1107
  if (!ok)
934
1108
  continue;
935
1109
  if (this._auditEnabled && this._audit.files[name]) {
@@ -938,7 +1112,6 @@ class RotateFileStream extends Writable {
938
1112
  }
939
1113
  else {
940
1114
  // untracked → record it fresh so future reconciles see truth
941
- const gzName = `${name}.gz`;
942
1115
  if (this._auditEnabled) {
943
1116
  this._audit.files[gzName] = {
944
1117
  state: "archived",
@@ -947,8 +1120,9 @@ class RotateFileStream extends Writable {
947
1120
  };
948
1121
  this._aevent("seal-past", { file: gzName });
949
1122
  }
950
- this.emit("archive", path.join(this.dir, gzName));
1123
+ this.emit("archive", gzName);
951
1124
  }
1125
+ await this._runSealHook(full, gzName);
952
1126
  }
953
1127
  }
954
1128
  async _repairInterruptedGzips() {
@@ -971,11 +1145,13 @@ class RotateFileStream extends Writable {
971
1145
  /* ignore */
972
1146
  }
973
1147
  this._finishArchivedRecord(name, true);
1148
+ await this._runSealHook(src, dst);
974
1149
  continue;
975
1150
  }
976
- const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
1151
+ const ok = await gzipMove(src, dst, (e) => this.emit("warn", e), this._compressionLevel);
977
1152
  if (ok) {
978
1153
  this._finishArchivedRecord(name, true);
1154
+ await this._runSealHook(src, dst);
979
1155
  }
980
1156
  else {
981
1157
  rec.gzipPending = false;
@@ -1008,14 +1184,19 @@ class RotateFileStream extends Writable {
1008
1184
  * ================================================================ */
1009
1185
  async _retire() {
1010
1186
  const policy = this.maxFiles;
1011
- if (!policy)
1012
- return;
1187
+ if (!policy && this.maxTotalSize <= 0)
1188
+ return; // budget gates too
1013
1189
  const activeBase = this._path && path.basename(this._path);
1014
1190
  const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
1015
1191
  const reserved = [
1016
1192
  path.basename(this._auditPath),
1017
1193
  `${path.basename(this._auditPath)}.tmp`,
1018
1194
  `${path.basename(this._auditPath)}.corrupt`,
1195
+ // never eat the "current" pointer (or its tmp)
1196
+ ...(this._symlinkPath
1197
+ ? [path.basename(this._symlinkPath),
1198
+ `${path.basename(this._symlinkPath)}.tmp`]
1199
+ : []),
1019
1200
  ];
1020
1201
  try {
1021
1202
  const dirEntries = await fs.promises.readdir(this.dir);
@@ -1027,7 +1208,8 @@ class RotateFileStream extends Writable {
1027
1208
  for (const name of candidates) {
1028
1209
  const full = path.join(this.dir, name);
1029
1210
  try {
1030
- stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
1211
+ const st = await fs.promises.stat(full);
1212
+ stats.push({ full, mtimeMs: st.mtimeMs, size: st.size }); // size
1031
1213
  }
1032
1214
  catch {
1033
1215
  /* raced deletion */
@@ -1035,18 +1217,30 @@ class RotateFileStream extends Writable {
1035
1217
  }
1036
1218
  stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
1037
1219
  const kill = new Set();
1038
- if (policy.days != null) {
1220
+ if (policy?.days != null) {
1039
1221
  const cutoff = this._now() - policy.days * 86_400_000;
1040
1222
  for (const f of stats)
1041
1223
  if (f.mtimeMs < cutoff)
1042
1224
  kill.add(f.full);
1043
1225
  }
1044
- if (policy.count != null) {
1226
+ if (policy?.count != null) {
1045
1227
  const survivors = stats.filter((f) => !kill.has(f.full));
1046
1228
  const excessCount = survivors.length - policy.count;
1047
1229
  for (let i = 0; i < excessCount; i++)
1048
1230
  kill.add(survivors[i].full);
1049
1231
  }
1232
+ // byte budget — delete OLDEST until under budget.
1233
+ // The active segment is never a candidate, so only archives count.
1234
+ if (this.maxTotalSize > 0) {
1235
+ const survivors = stats.filter((f) => !kill.has(f.full)); // oldest→newest
1236
+ let total = survivors.reduce((sum, f) => sum + f.size, 0);
1237
+ let i = 0;
1238
+ while (total > this.maxTotalSize && i < survivors.length) {
1239
+ kill.add(survivors[i].full);
1240
+ total -= survivors[i].size;
1241
+ i++;
1242
+ }
1243
+ }
1050
1244
  if (kill.size === 0)
1051
1245
  return;
1052
1246
  for (const file of kill) {
@@ -1074,4 +1268,64 @@ function createStream(options) {
1074
1268
  return new RotateFileStream(options);
1075
1269
  }
1076
1270
 
1077
- export { RotateFileStream, createStream };
1271
+ const registry = new Set();
1272
+ let installed = false;
1273
+ let inProgress = false;
1274
+ /**
1275
+ * Register streams for graceful shutdown: on SIGINT/SIGTERM every
1276
+ * registered stream is ended — flushing buffered writes and finishing the
1277
+ * audit manifest — before the process exits, with a hard deadline.
1278
+ *
1279
+ * Safe to call multiple times: process listeners install once, streams
1280
+ * accumulate in the registry.
1281
+ *
1282
+ * Returns `flush()`, the same graceful end WITHOUT exiting — for tests
1283
+ * and custom signal handling.
1284
+ *
1285
+ * @example
1286
+ * const flush = installShutdown(logStream);
1287
+ * process.on("exit", () => { void flush(); });
1288
+ */
1289
+ function installShutdown(streams, opts = {}) {
1290
+ const list = Array.isArray(streams) ? streams : [streams];
1291
+ for (const s of list)
1292
+ registry.add(s);
1293
+ if (!installed) {
1294
+ installed = true;
1295
+ const timeoutMs = opts.timeoutMs ?? 5_000;
1296
+ const code = opts.code ?? 0;
1297
+ for (const sig of opts.signals ?? ["SIGINT", "SIGTERM"]) {
1298
+ process.on(sig, () => {
1299
+ void flushAll(timeoutMs).finally(() => process.exit(code));
1300
+ });
1301
+ }
1302
+ }
1303
+ return async () => {
1304
+ // flush FIRST — flushAll() drains the registry, so unregistering
1305
+ // before flushing would flush nothing (caught by the shutdown test)
1306
+ await flushAll(opts.timeoutMs ?? 5_000);
1307
+ for (const s of list)
1308
+ registry.delete(s);
1309
+ };
1310
+ }
1311
+ /**
1312
+ * End every registered stream (flush buffered writes) with a deadline.
1313
+ * Does NOT exit the process. Double-ending an already-ended stream is
1314
+ * harmless — the error lands in the ignored callback.
1315
+ */
1316
+ async function flushAll(timeoutMs = 5_000) {
1317
+ if (inProgress)
1318
+ return;
1319
+ inProgress = true;
1320
+ const deadline = setTimeout(() => process.exit(1), timeoutMs);
1321
+ deadline.unref();
1322
+ try {
1323
+ await Promise.allSettled([...registry].map((s) => new Promise((res) => s.end(() => res()))));
1324
+ }
1325
+ finally {
1326
+ clearTimeout(deadline);
1327
+ inProgress = false;
1328
+ }
1329
+ }
1330
+
1331
+ export { RotateFileStream, createStream, flushAll, installShutdown };
package/dist/parsers.d.ts CHANGED
@@ -12,5 +12,7 @@ export type ParsedFrequency = {
12
12
  export declare function parseSize(v: RotateFileStreamOptions["maxSize"]): number;
13
13
  export declare function parseMaxFiles(v: RotateFileStreamOptions["maxFiles"]): ParsedMaxFiles | null;
14
14
  export declare function parseFrequency(v: RotateFileStreamOptions["frequency"]): ParsedFrequency;
15
+ /** Static virtual-clock shift. Accepts hours by default; h/hours/m/s/ms. */
15
16
  export declare function parseClockOffset(v: RotateFileStreamOptions["addHours"]): number;
17
+ /** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
16
18
  export declare function parseClockStep(v: RotateFileStreamOptions["addHoursEveryMin"]): number;
@@ -1,19 +1,24 @@
1
1
  import { Writable } from "node:stream";
2
2
  import { type ParsedFrequency, type ParsedMaxFiles } from "./parsers.js";
3
- import type { RotateFileStreamEventMap, RotateFileStreamOptions } from "./types.js";
3
+ import type { RotateFileStreamEventMap, RotateFileStreamOptions, SegmentInfo, StreamStats } from "./types.js";
4
4
  /**
5
5
  * A `Writable` that appends to dated log files, rolling segments on time
6
- * and/or size, optionally gzipping sealed segments, enforcing retention,
7
- * and resuming an unbroken series across restarts via disk scan + audit
8
- * manifest.
6
+ * and/or size, optionally gzipping sealed segments, enforcing retention
7
+ * (count, age, and total-byte budget), maintaining a stable "current"
8
+ * symlink for log collectors, running an awaited seal hook (upload
9
+ * pipeline), and resuming an unbroken series across restarts via disk
10
+ * scan + audit manifest.
9
11
  *
10
12
  * @example
11
13
  * ```ts
12
14
  * const log = createStream({
13
15
  * filename: "logs/app-%DATE%.log",
14
16
  * maxSize: "20m",
17
+ * maxTotalSize: "5g",
15
18
  * maxFiles: "90d",
16
19
  * zippedArchive: true,
20
+ * symlink: true,
21
+ * onSeal: async ({ gz }) => uploadToS3(gz),
17
22
  * });
18
23
  * log.write("hello\n"); // or: readable.pipe(log)
19
24
  * ```
@@ -28,6 +33,7 @@ export declare class RotateFileStream extends Writable {
28
33
  readonly maxFiles: Readonly<ParsedMaxFiles> | null;
29
34
  readonly freq: ParsedFrequency;
30
35
  readonly unrefTimers: boolean;
36
+ readonly maxTotalSize: number;
31
37
  private readonly _tL;
32
38
  private readonly _tMid;
33
39
  private readonly _tExt;
@@ -36,6 +42,10 @@ export declare class RotateFileStream extends Writable {
36
42
  private readonly _auditEnabled;
37
43
  private readonly _auditPath;
38
44
  private readonly _bootId;
45
+ private readonly _onSeal?;
46
+ private readonly _sealHookTimeoutMs;
47
+ private readonly _compressionLevel;
48
+ private _symlinkPath;
39
49
  private _offsetMs;
40
50
  private _stepMs;
41
51
  private _bumpEvery;
@@ -69,6 +79,15 @@ export declare class RotateFileStream extends Writable {
69
79
  * archive flow as a real midnight roll.
70
80
  */
71
81
  advanceClock(): Promise<void>;
82
+ /**
83
+ * Every family file on disk, oldest stamp first.
84
+ * Disk is truth: untracked files are reported with state "untracked".
85
+ */
86
+ listSegments(): SegmentInfo[];
87
+ /**
88
+ * Health-check snapshot for dashboards and support triage.
89
+ */
90
+ stats(): StreamStats;
72
91
  _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void;
73
92
  _final(callback: (error?: Error | null) => void): void;
74
93
  destroy(...args: Parameters<Writable["destroy"]>): this;
@@ -89,6 +108,19 @@ export declare class RotateFileStream extends Writable {
89
108
  * The audit pointer (same stamp only) may only RAISE the result.
90
109
  */
91
110
  private _recoverIndexFor;
111
+ /**
112
+ * Atomically flip the "current" symlink to the active segment.
113
+ * tmp + rename so followers (`tail -f`, Filebeat) never observe a gap.
114
+ * Relative target keeps the link portable. Best-effort: filesystems
115
+ * denying symlinks warn once and disable the feature permanently.
116
+ */
117
+ private _updateSymlink;
118
+ /**
119
+ * Await the user's onSeal hook with a hard timeout. Result or
120
+ * failure degrades to `warn` — a wedged upload must never stall rotation.
121
+ * Called BEFORE retention so uploads finish before deletion.
122
+ */
123
+ private _runSealHook;
92
124
  private _ensureOpen;
93
125
  private _rotate;
94
126
  private _hasContent;
@@ -0,0 +1,32 @@
1
+ import type { RotateFileStream } from "./rotate-file-stream.js";
2
+ export interface ShutdownOptions {
3
+ /** Signals to listen on. @default ["SIGINT", "SIGTERM"] */
4
+ signals?: NodeJS.Signals[];
5
+ /** Hard-exit deadline (ms) if a stream refuses to end.
6
+ * @default 5000 */
7
+ timeoutMs?: number;
8
+ /** Process exit code. @default 0 */
9
+ code?: number;
10
+ }
11
+ /**
12
+ * Register streams for graceful shutdown: on SIGINT/SIGTERM every
13
+ * registered stream is ended — flushing buffered writes and finishing the
14
+ * audit manifest — before the process exits, with a hard deadline.
15
+ *
16
+ * Safe to call multiple times: process listeners install once, streams
17
+ * accumulate in the registry.
18
+ *
19
+ * Returns `flush()`, the same graceful end WITHOUT exiting — for tests
20
+ * and custom signal handling.
21
+ *
22
+ * @example
23
+ * const flush = installShutdown(logStream);
24
+ * process.on("exit", () => { void flush(); });
25
+ */
26
+ export declare function installShutdown(streams: RotateFileStream | RotateFileStream[], opts?: ShutdownOptions): () => Promise<void>;
27
+ /**
28
+ * End every registered stream (flush buffered writes) with a deadline.
29
+ * Does NOT exit the process. Double-ending an already-ended stream is
30
+ * harmless — the error lands in the ignored callback.
31
+ */
32
+ export declare function flushAll(timeoutMs?: number): Promise<void>;