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