logroller 1.0.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/LICENSE +5 -0
- package/README.md +147 -0
- package/dist/index.cjs +875 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.esm.js +872 -0
- package/package.json +59 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
/*! logroller v1.0.0 | MIT License */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import zlib from 'node:zlib';
|
|
5
|
+
import { Writable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
/* ================================================================
|
|
10
|
+
* Option parsers
|
|
11
|
+
* ================================================================ */
|
|
12
|
+
const UNITS = {
|
|
13
|
+
b: 1, k: 1024, kb: 1024,
|
|
14
|
+
m: 1048576, mb: 1048576,
|
|
15
|
+
g: 1073741824, gb: 1073741824,
|
|
16
|
+
};
|
|
17
|
+
const FREQ_MULT = { ms: 1, s: 1000, m: 60000, h: 3600000 };
|
|
18
|
+
function parseSize(v) {
|
|
19
|
+
if (v === undefined || v === null || v === 0)
|
|
20
|
+
return 0;
|
|
21
|
+
if (typeof v === "number")
|
|
22
|
+
return v;
|
|
23
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|kb|m|mb|g|gb)?\s*$/i.exec(v);
|
|
24
|
+
if (!m)
|
|
25
|
+
throw new Error(`Invalid maxSize "${v}"`);
|
|
26
|
+
return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
|
|
27
|
+
}
|
|
28
|
+
function parseMaxFiles(v) {
|
|
29
|
+
if (v === undefined || v === null || v === "" || v === 0)
|
|
30
|
+
return null;
|
|
31
|
+
if (typeof v === "number")
|
|
32
|
+
return { count: v };
|
|
33
|
+
let m = /^\s*(\d+)\s*d\s*$/i.exec(v);
|
|
34
|
+
if (m)
|
|
35
|
+
return { days: Number.parseInt(m[1], 10) };
|
|
36
|
+
m = /^\s*(\d+)\s*$/.exec(v);
|
|
37
|
+
if (m)
|
|
38
|
+
return { count: Number.parseInt(m[1], 10) };
|
|
39
|
+
throw new Error(`Invalid maxFiles "${v}"`);
|
|
40
|
+
}
|
|
41
|
+
function parseFrequency(v) {
|
|
42
|
+
if (v === undefined || v === null)
|
|
43
|
+
v = "daily";
|
|
44
|
+
if (v === "daily")
|
|
45
|
+
return { type: "daily" };
|
|
46
|
+
if (typeof v === "number" && v > 0)
|
|
47
|
+
return { type: "interval", ms: v };
|
|
48
|
+
const m = /^\s*(\d+)\s*(ms|s|m|h)\s*$/i.exec(String(v));
|
|
49
|
+
if (!m)
|
|
50
|
+
throw new Error(`Invalid frequency "${v}"`);
|
|
51
|
+
const mult = FREQ_MULT[m[2].toLowerCase()] ?? 1; // ◀ TS FIX
|
|
52
|
+
return { type: "interval", ms: Number.parseInt(m[1], 10) * mult };
|
|
53
|
+
}
|
|
54
|
+
function parseClockOffset(v) {
|
|
55
|
+
if (v === undefined || v === null || v === "")
|
|
56
|
+
return 0;
|
|
57
|
+
if (typeof v === "number")
|
|
58
|
+
return Math.round(v * 3600000);
|
|
59
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*h(?:our)?s?\s*$/i.exec(v);
|
|
60
|
+
if (!m)
|
|
61
|
+
throw new Error(`Invalid addHours "${v}"`);
|
|
62
|
+
return Math.round(Number.parseFloat(m[1]) * 3600000);
|
|
63
|
+
}
|
|
64
|
+
function parseClockStep(v) {
|
|
65
|
+
if (v === undefined || v === null || v === "" || v === 0)
|
|
66
|
+
return 0;
|
|
67
|
+
if (typeof v === "number")
|
|
68
|
+
return Math.round(v * 3600000);
|
|
69
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(v);
|
|
70
|
+
if (!m)
|
|
71
|
+
throw new Error(`Invalid addHoursEveryMin "${v}"`);
|
|
72
|
+
const mult = FREQ_MULT[(m[2] || "h").toLowerCase()] ?? 1; // ◀ TS FIX
|
|
73
|
+
return Math.round(Number.parseFloat(m[1]) * mult);
|
|
74
|
+
}
|
|
75
|
+
/* ================================================================
|
|
76
|
+
* Timezone helpers
|
|
77
|
+
* ================================================================ */
|
|
78
|
+
const _dtfCache = new Map();
|
|
79
|
+
function getDtf(tz) {
|
|
80
|
+
let d = _dtfCache.get(tz);
|
|
81
|
+
if (!d) {
|
|
82
|
+
try {
|
|
83
|
+
d = new Intl.DateTimeFormat("en-US", {
|
|
84
|
+
timeZone: tz,
|
|
85
|
+
hourCycle: "h23",
|
|
86
|
+
year: "numeric",
|
|
87
|
+
month: "2-digit",
|
|
88
|
+
day: "2-digit",
|
|
89
|
+
hour: "2-digit",
|
|
90
|
+
minute: "2-digit",
|
|
91
|
+
second: "2-digit",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error(`Invalid timeZone "${tz}"`);
|
|
96
|
+
}
|
|
97
|
+
_dtfCache.set(tz, d);
|
|
98
|
+
}
|
|
99
|
+
return d;
|
|
100
|
+
}
|
|
101
|
+
function partsIn(tz, date) {
|
|
102
|
+
const o = {};
|
|
103
|
+
for (const p of getDtf(tz).formatToParts(date))
|
|
104
|
+
o[p.type] = p.value;
|
|
105
|
+
return o;
|
|
106
|
+
}
|
|
107
|
+
const pad2 = (n) => n.padStart(2, "0");
|
|
108
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
109
|
+
function formatStamp(pattern, parts) {
|
|
110
|
+
return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (t) => t === "YYYY" ? parts.year :
|
|
111
|
+
t === "YY" ? parts.year.slice(-2) :
|
|
112
|
+
t === "MM" ? pad2(parts.month) :
|
|
113
|
+
t === "DD" ? pad2(parts.day) :
|
|
114
|
+
t === "HH" ? pad2(parts.hour) :
|
|
115
|
+
t === "mm" ? pad2(parts.minute) :
|
|
116
|
+
t === "ss" ? pad2(parts.second) : t);
|
|
117
|
+
}
|
|
118
|
+
function tzOffsetMs(tz, date) {
|
|
119
|
+
const p = partsIn(tz, date);
|
|
120
|
+
return (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) -
|
|
121
|
+
(date.getTime() - (date.getTime() % 1000)));
|
|
122
|
+
}
|
|
123
|
+
function nextMidnight(tz, fromMs) {
|
|
124
|
+
const p = partsIn(tz, new Date(fromMs));
|
|
125
|
+
const localNext = Date.UTC(+p.year, +p.month - 1, +p.day + 1);
|
|
126
|
+
let inst = localNext - tzOffsetMs(tz, new Date(localNext));
|
|
127
|
+
for (let i = 0; i < 3; i++) {
|
|
128
|
+
const cand = localNext - tzOffsetMs(tz, new Date(inst));
|
|
129
|
+
if (cand === inst)
|
|
130
|
+
break;
|
|
131
|
+
inst = cand;
|
|
132
|
+
}
|
|
133
|
+
if (inst <= fromMs)
|
|
134
|
+
inst += 86400000;
|
|
135
|
+
return inst;
|
|
136
|
+
}
|
|
137
|
+
const nextAligned = (from, step) => Math.floor(from / step) * step + step;
|
|
138
|
+
/* ================================================================
|
|
139
|
+
* Utilities
|
|
140
|
+
* ================================================================ */
|
|
141
|
+
const statSizeSafe = (p) => {
|
|
142
|
+
try {
|
|
143
|
+
return typeof p === "string" ? fs.statSync(p).size : 0;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const existsSafe = (p) => {
|
|
150
|
+
try {
|
|
151
|
+
return fs.existsSync(p);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
/** ◀ TS FIX: takes an onWarn CALLBACK instead of an emitter object,
|
|
158
|
+
* so the strictly-typed `emit` can be passed without casts. */
|
|
159
|
+
async function gzipMove(src, dst, onWarn) {
|
|
160
|
+
try {
|
|
161
|
+
await pipeline(fs.createReadStream(src), zlib.createGzip({ level: 6 }), fs.createWriteStream(dst));
|
|
162
|
+
await fs.promises.unlink(src);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
await fs.promises.unlink(dst).catch(() => { });
|
|
167
|
+
onWarn?.(err);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const endStream = (ws) => ws ? new Promise((r) => ws.end(() => r())) : Promise.resolve();
|
|
172
|
+
const BUMP_DEFAULT_MS = 60_000;
|
|
173
|
+
const AUDIT_MAX_FILES = 600;
|
|
174
|
+
const HEARTBEAT_STALE_MS = 30_000;
|
|
175
|
+
/* ================================================================
|
|
176
|
+
* Main class
|
|
177
|
+
* ================================================================ */
|
|
178
|
+
class RotateFileStream extends Writable {
|
|
179
|
+
tz;
|
|
180
|
+
template;
|
|
181
|
+
dir;
|
|
182
|
+
datePattern;
|
|
183
|
+
zippedArchive;
|
|
184
|
+
maxSize;
|
|
185
|
+
maxFiles;
|
|
186
|
+
freq;
|
|
187
|
+
unrefTimers;
|
|
188
|
+
_tL;
|
|
189
|
+
_tMid;
|
|
190
|
+
_tExt;
|
|
191
|
+
_rxFor;
|
|
192
|
+
_rxActive;
|
|
193
|
+
_auditEnabled;
|
|
194
|
+
_auditPath;
|
|
195
|
+
_bootId = randomUUID();
|
|
196
|
+
_offsetMs;
|
|
197
|
+
_stepMs;
|
|
198
|
+
_bumpEvery;
|
|
199
|
+
_curStamp;
|
|
200
|
+
_index;
|
|
201
|
+
_ws = null;
|
|
202
|
+
_path = null;
|
|
203
|
+
_bytes = 0;
|
|
204
|
+
_timer = null;
|
|
205
|
+
_bumpTimer = null;
|
|
206
|
+
_ended = false;
|
|
207
|
+
_tail = Promise.resolve();
|
|
208
|
+
_audit;
|
|
209
|
+
// ◀ TS FIX: Partial<> + runtime validation (default {} is legal now)
|
|
210
|
+
constructor(opts = {}) {
|
|
211
|
+
super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
|
|
212
|
+
if (typeof opts.filename !== "string" || !opts.filename.includes("%DATE%"))
|
|
213
|
+
throw new Error("opts.filename must contain '%DATE%'");
|
|
214
|
+
this.tz = opts.tz || "UTC";
|
|
215
|
+
getDtf(this.tz);
|
|
216
|
+
this.template = path.basename(opts.filename);
|
|
217
|
+
this.dir = path.dirname(opts.filename);
|
|
218
|
+
this.datePattern = opts.datePattern || "YYYY-MM-DD";
|
|
219
|
+
this.zippedArchive = !!opts.zippedArchive;
|
|
220
|
+
this.maxSize = parseSize(opts.maxSize ?? 0);
|
|
221
|
+
this.maxFiles = parseMaxFiles(opts.maxFiles);
|
|
222
|
+
this.freq = parseFrequency(opts.frequency);
|
|
223
|
+
this.unrefTimers = !!opts.unrefTimers;
|
|
224
|
+
this._offsetMs = parseClockOffset(opts.addHours);
|
|
225
|
+
this._stepMs = parseClockStep(opts.addHoursEveryMin);
|
|
226
|
+
this._bumpEvery = Math.max(50, opts.clockStepIntervalMs ?? BUMP_DEFAULT_MS);
|
|
227
|
+
const li = this.template.indexOf("%DATE%");
|
|
228
|
+
const rest = this.template.slice(li + 6);
|
|
229
|
+
const dot = rest.lastIndexOf(".");
|
|
230
|
+
this._tL = this.template.slice(0, li);
|
|
231
|
+
this._tMid = dot >= 0 ? rest.slice(0, dot) : "";
|
|
232
|
+
this._tExt = dot >= 0 ? rest.slice(dot) : "";
|
|
233
|
+
this._rxFor = (stampSrc) => new RegExp("^" + escRe(this._tL) + stampSrc + escRe(this._tMid) +
|
|
234
|
+
"(?:\\.(\\d+))?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
235
|
+
this._rxActive = this._rxFor("[^.]+");
|
|
236
|
+
this._auditEnabled = opts.audit !== false;
|
|
237
|
+
const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log").replace(/^[-.]+|[-.]+$/, "");
|
|
238
|
+
this._auditPath = path.join(this.dir, opts.auditFile || `${stem}_audit.json`);
|
|
239
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
240
|
+
this._curStamp = this._stampNow();
|
|
241
|
+
this._resetAuditObject();
|
|
242
|
+
const hadAudit = this._auditEnabled && this._loadAudit();
|
|
243
|
+
this._index = this._recoverIndexFor(this._curStamp);
|
|
244
|
+
// ◀ TS FIX: local const narrows away null (Number.isInteger doesn't)
|
|
245
|
+
const ai = this._audit.activeIndex;
|
|
246
|
+
if (hadAudit && ai !== null && this._audit.activeStamp === this._curStamp) {
|
|
247
|
+
this._index = Math.max(this._index, ai);
|
|
248
|
+
}
|
|
249
|
+
if (this._offsetMs !== 0 || this._stepMs !== 0)
|
|
250
|
+
process.nextTick(() => this.emit("clock", {
|
|
251
|
+
addHours: opts.addHours ?? undefined,
|
|
252
|
+
addHoursEveryMin: opts.addHoursEveryMin ?? undefined,
|
|
253
|
+
offsetMs: this._offsetMs,
|
|
254
|
+
}));
|
|
255
|
+
/* boot audit pass = job #1 in the serial queue.
|
|
256
|
+
Chained HERE (not nextTick) so it always runs before any write job:
|
|
257
|
+
zombie demotion sees _path === null, and gzip-repair can never race
|
|
258
|
+
with a write reopening a pending file. */
|
|
259
|
+
this._job(async () => {
|
|
260
|
+
if (this._auditEnabled) {
|
|
261
|
+
this._demoteZombieActives();
|
|
262
|
+
this._reconcileWithDisk();
|
|
263
|
+
await this._repairInterruptedGzips();
|
|
264
|
+
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
265
|
+
this._saveAudit();
|
|
266
|
+
}
|
|
267
|
+
if (this.maxFiles)
|
|
268
|
+
this._retire();
|
|
269
|
+
}).catch(() => { });
|
|
270
|
+
this._armTimer();
|
|
271
|
+
this._armBump();
|
|
272
|
+
this.once("close", () => {
|
|
273
|
+
this._stopTimer();
|
|
274
|
+
this._stopBump();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/* ---------------- public ---------------- */
|
|
278
|
+
get filename() {
|
|
279
|
+
return this._path;
|
|
280
|
+
}
|
|
281
|
+
get currentIndex() {
|
|
282
|
+
return this._index;
|
|
283
|
+
}
|
|
284
|
+
_now() {
|
|
285
|
+
return Date.now() + this._offsetMs;
|
|
286
|
+
}
|
|
287
|
+
rotateNow() {
|
|
288
|
+
return this._job(() => this._rotate("manual"));
|
|
289
|
+
}
|
|
290
|
+
/** Apply one virtual-clock step immediately (test helper). */
|
|
291
|
+
advanceClock() {
|
|
292
|
+
return this._job(() => this._applyBump());
|
|
293
|
+
}
|
|
294
|
+
/* ---------------- audit internals ---------------- */
|
|
295
|
+
_resetAuditObject() {
|
|
296
|
+
this._audit = {
|
|
297
|
+
version: 1,
|
|
298
|
+
prefix: this._tL,
|
|
299
|
+
ext: this._tExt,
|
|
300
|
+
datePattern: this.datePattern,
|
|
301
|
+
tz: this.tz,
|
|
302
|
+
createdAt: null,
|
|
303
|
+
updatedAt: null,
|
|
304
|
+
activeStamp: null,
|
|
305
|
+
activeIndex: null,
|
|
306
|
+
writer: null,
|
|
307
|
+
files: {},
|
|
308
|
+
events: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
_loadAudit() {
|
|
312
|
+
try {
|
|
313
|
+
let raw;
|
|
314
|
+
try {
|
|
315
|
+
raw = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
const j = raw;
|
|
321
|
+
if (j && j.version === 1 && j.prefix === this._tL && j.ext === this._tExt) {
|
|
322
|
+
this._audit.createdAt = j.createdAt || new Date().toISOString();
|
|
323
|
+
this._audit.updatedAt = null;
|
|
324
|
+
this._audit.activeStamp =
|
|
325
|
+
typeof j.activeStamp === "string" ? j.activeStamp : null;
|
|
326
|
+
this._audit.activeIndex =
|
|
327
|
+
Number.isInteger(j.activeIndex) ? j.activeIndex : null;
|
|
328
|
+
this._audit.files =
|
|
329
|
+
j.files && typeof j.files === "object"
|
|
330
|
+
? j.files
|
|
331
|
+
: {};
|
|
332
|
+
this._audit.events = Array.isArray(j.events)
|
|
333
|
+
? j.events.slice(-200)
|
|
334
|
+
: [];
|
|
335
|
+
const w = j.writer;
|
|
336
|
+
if (w && w.bootId && w.bootId !== this._bootId &&
|
|
337
|
+
typeof w.heartbeat === "number" &&
|
|
338
|
+
Date.now() - w.heartbeat < HEARTBEAT_STALE_MS) {
|
|
339
|
+
const err = new Error(`concurrent writer detected (pid ${w.pid}, boot ${w.bootId}). ` +
|
|
340
|
+
`Two processes are sharing this log family — results will race. ` +
|
|
341
|
+
`If using 'node --watch', add --watch-path to exclude the logs dir.`);
|
|
342
|
+
this.emit("warn", err);
|
|
343
|
+
this._audit.events.push({
|
|
344
|
+
id: randomUUID(),
|
|
345
|
+
t: new Date().toISOString(),
|
|
346
|
+
type: "concurrent-writer",
|
|
347
|
+
otherPid: w.pid,
|
|
348
|
+
otherBoot: w.bootId,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
fs.renameSync(this._auditPath, this._auditPath + ".corrupt");
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
/* ignore */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
/* fallthrough */
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
_saveAudit() {
|
|
366
|
+
if (!this._auditEnabled)
|
|
367
|
+
return;
|
|
368
|
+
try {
|
|
369
|
+
this._pruneAuditFiles();
|
|
370
|
+
this._audit.updatedAt = new Date().toISOString();
|
|
371
|
+
this._audit.writer = {
|
|
372
|
+
pid: process.pid,
|
|
373
|
+
bootId: this._bootId,
|
|
374
|
+
heartbeat: Date.now(),
|
|
375
|
+
};
|
|
376
|
+
const tmp = this._auditPath + ".tmp";
|
|
377
|
+
fs.writeFileSync(tmp, JSON.stringify(this._audit, null, 1));
|
|
378
|
+
fs.renameSync(tmp, this._auditPath);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
this.emit("warn", e);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
_pruneAuditFiles() {
|
|
385
|
+
const f = this._audit.files;
|
|
386
|
+
const keys = Object.keys(f);
|
|
387
|
+
if (keys.length <= AUDIT_MAX_FILES)
|
|
388
|
+
return;
|
|
389
|
+
const activeBase = this._path && path.basename(this._path);
|
|
390
|
+
const dated = (r) => r.archivedAt || r.closedAt || r.abandonedAt || r.openedAt || "";
|
|
391
|
+
const stale = keys
|
|
392
|
+
.filter((k) => k !== activeBase && f[k].state !== "active")
|
|
393
|
+
.sort((a, b) => dated(f[a]).localeCompare(dated(f[b])));
|
|
394
|
+
for (const k of stale.slice(0, keys.length - AUDIT_MAX_FILES + 50))
|
|
395
|
+
delete f[k];
|
|
396
|
+
}
|
|
397
|
+
_aevent(type, extra = {}) {
|
|
398
|
+
if (!this._auditEnabled)
|
|
399
|
+
return;
|
|
400
|
+
this._audit.events.push({
|
|
401
|
+
id: randomUUID(),
|
|
402
|
+
t: new Date().toISOString(),
|
|
403
|
+
type,
|
|
404
|
+
...extra,
|
|
405
|
+
});
|
|
406
|
+
if (this._audit.events.length > 200)
|
|
407
|
+
this._audit.events.splice(0, this._audit.events.length - 200);
|
|
408
|
+
}
|
|
409
|
+
_setActivePointer(index) {
|
|
410
|
+
this._audit.activeStamp = this._curStamp;
|
|
411
|
+
this._audit.activeIndex = index;
|
|
412
|
+
}
|
|
413
|
+
_demoteZombieActives() {
|
|
414
|
+
const activeBase = this._path && path.basename(this._path);
|
|
415
|
+
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
416
|
+
if (rec.state === "active" && name !== activeBase) {
|
|
417
|
+
rec.state = "abandoned";
|
|
418
|
+
rec.abandonedAt = new Date().toISOString();
|
|
419
|
+
this._aevent("abandon", { file: name });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
_reconcileWithDisk() {
|
|
424
|
+
for (const name of Object.keys(this._audit.files)) {
|
|
425
|
+
const rec = this._audit.files[name];
|
|
426
|
+
if (rec.gzipPending)
|
|
427
|
+
continue;
|
|
428
|
+
if (name.endsWith(".gz")) {
|
|
429
|
+
const gzExists = existsSafe(path.join(this.dir, name));
|
|
430
|
+
const rawName = name.slice(0, -3);
|
|
431
|
+
const rawExists = existsSafe(path.join(this.dir, rawName));
|
|
432
|
+
if (gzExists)
|
|
433
|
+
continue;
|
|
434
|
+
if (rawExists) {
|
|
435
|
+
delete this._audit.files[name];
|
|
436
|
+
this._audit.files[rawName] = { ...rec, state: "sealed" };
|
|
437
|
+
this._aevent("archive-regressed", { file: name, backTo: rawName });
|
|
438
|
+
this.emit("warn", new Error(`archive ${name} missing but raw ${rawName} exists — record reverted`));
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
this._aevent("vanished", { file: name });
|
|
442
|
+
delete this._audit.files[name];
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const rawExists = existsSafe(path.join(this.dir, name));
|
|
446
|
+
const gzExists = existsSafe(path.join(this.dir, name + ".gz"));
|
|
447
|
+
if (rawExists)
|
|
448
|
+
continue;
|
|
449
|
+
if (gzExists) {
|
|
450
|
+
this._finishArchivedRecord(name, true);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
this._aevent("vanished", { file: name });
|
|
454
|
+
delete this._audit.files[name];
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async _repairInterruptedGzips() {
|
|
458
|
+
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
459
|
+
if (!rec.gzipPending)
|
|
460
|
+
continue;
|
|
461
|
+
const src = path.join(this.dir, name);
|
|
462
|
+
const dst = src + ".gz";
|
|
463
|
+
if (!existsSafe(src)) {
|
|
464
|
+
rec.gzipPending = false;
|
|
465
|
+
rec.state = "lost";
|
|
466
|
+
this._aevent("gzip-lost", { file: name });
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (existsSafe(dst)) {
|
|
470
|
+
try {
|
|
471
|
+
fs.unlinkSync(src);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
/* ignore */
|
|
475
|
+
}
|
|
476
|
+
this._finishArchivedRecord(name, true);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// ◀ TS FIX: callback instead of emitter object
|
|
480
|
+
const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
|
|
481
|
+
if (ok) {
|
|
482
|
+
this._finishArchivedRecord(name, true);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
rec.gzipPending = false;
|
|
486
|
+
rec.state = "kept-raw";
|
|
487
|
+
this._aevent("gzip-failed", { file: name });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
_finishArchivedRecord(rawName, repaired = false) {
|
|
492
|
+
const rec = this._audit.files[rawName];
|
|
493
|
+
if (!rec)
|
|
494
|
+
return;
|
|
495
|
+
rec.gzipPending = false;
|
|
496
|
+
rec.state = "archived";
|
|
497
|
+
rec.archivedAt = new Date().toISOString();
|
|
498
|
+
if (rawName.endsWith(".gz")) {
|
|
499
|
+
this.emit("archive", path.join(this.dir, rawName));
|
|
500
|
+
this._aevent("archive", { file: rawName, ...(repaired && { repaired }) });
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
const gzName = rawName + ".gz";
|
|
504
|
+
delete this._audit.files[rawName];
|
|
505
|
+
this._audit.files[gzName] = rec;
|
|
506
|
+
this.emit("archive", path.join(this.dir, gzName));
|
|
507
|
+
this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/* ---------------- Writable ---------------- */
|
|
511
|
+
_write(chunk, encoding, cb) {
|
|
512
|
+
if (this._ended) {
|
|
513
|
+
process.nextTick(cb, new Error("write after end"));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const len = chunk.length;
|
|
517
|
+
this._job(async () => {
|
|
518
|
+
try {
|
|
519
|
+
const s = this._stampNow();
|
|
520
|
+
if (s !== this._curStamp)
|
|
521
|
+
await this._rotate("time");
|
|
522
|
+
await this._ensureOpen();
|
|
523
|
+
if (this.maxSize > 0 &&
|
|
524
|
+
this._bytes > 0 &&
|
|
525
|
+
this._bytes + len > this.maxSize) {
|
|
526
|
+
await this._rotate("size");
|
|
527
|
+
await this._ensureOpen();
|
|
528
|
+
}
|
|
529
|
+
// ◀ TS FIX: narrow instead of `this._ws!`
|
|
530
|
+
const ws = this._ws;
|
|
531
|
+
if (!ws)
|
|
532
|
+
throw new Error("write stream not open");
|
|
533
|
+
await new Promise((res, rej) => ws.write(chunk, encoding, (e) => (e ? rej(e) : res())));
|
|
534
|
+
this._bytes += len;
|
|
535
|
+
cb();
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
this.emit("error", err); // ok now: "error" in the events map
|
|
539
|
+
cb(err);
|
|
540
|
+
}
|
|
541
|
+
}).catch(() => { });
|
|
542
|
+
}
|
|
543
|
+
_final(cb) {
|
|
544
|
+
this._ended = true;
|
|
545
|
+
this._stopTimer();
|
|
546
|
+
this._stopBump();
|
|
547
|
+
this._job(async () => {
|
|
548
|
+
await endStream(this._ws);
|
|
549
|
+
const b = this._path && path.basename(this._path);
|
|
550
|
+
if (b && this._audit.files[b]) {
|
|
551
|
+
this._audit.files[b].state = "closed";
|
|
552
|
+
this._audit.files[b].closedAt = new Date().toISOString();
|
|
553
|
+
this._audit.files[b].bytes = statSizeSafe(this._path);
|
|
554
|
+
this._aevent("close", { file: b });
|
|
555
|
+
this._saveAudit();
|
|
556
|
+
}
|
|
557
|
+
this._ws = null;
|
|
558
|
+
this._path = null;
|
|
559
|
+
})
|
|
560
|
+
.catch(() => { })
|
|
561
|
+
.finally(() => cb());
|
|
562
|
+
}
|
|
563
|
+
destroy(...args) {
|
|
564
|
+
this._stopTimer();
|
|
565
|
+
this._stopBump();
|
|
566
|
+
return super.destroy(...args);
|
|
567
|
+
}
|
|
568
|
+
/* ---------------- core ---------------- */
|
|
569
|
+
_job(fn) {
|
|
570
|
+
const p = this._tail.then(fn, fn);
|
|
571
|
+
this._tail = p.catch(() => { });
|
|
572
|
+
return p;
|
|
573
|
+
}
|
|
574
|
+
_stampNow(d = new Date(this._now())) {
|
|
575
|
+
return formatStamp(this.datePattern, partsIn(this.tz, d));
|
|
576
|
+
}
|
|
577
|
+
_resolveName(stamp, index) {
|
|
578
|
+
const base = path.join(this.dir, this._tL + stamp + this._tMid + this._tExt);
|
|
579
|
+
if (!index)
|
|
580
|
+
return base;
|
|
581
|
+
return /\.[^.]+$/.test(base)
|
|
582
|
+
? base.replace(/\.[^.]+$/, `.${index}$&`)
|
|
583
|
+
: `${base}.${index}`;
|
|
584
|
+
}
|
|
585
|
+
_recoverIndexFor(stamp) {
|
|
586
|
+
const rx = this._rxFor(escRe(stamp));
|
|
587
|
+
let maxAny = -1;
|
|
588
|
+
let maxRaw = -1;
|
|
589
|
+
let names = [];
|
|
590
|
+
try {
|
|
591
|
+
names = fs.readdirSync(this.dir);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
/* ignore */
|
|
595
|
+
}
|
|
596
|
+
for (const n of names) {
|
|
597
|
+
const m = rx.exec(n);
|
|
598
|
+
if (!m)
|
|
599
|
+
continue;
|
|
600
|
+
const idx = m[1] ? Number.parseInt(m[1], 10) : 0;
|
|
601
|
+
if (idx > maxAny)
|
|
602
|
+
maxAny = idx;
|
|
603
|
+
if (!n.endsWith(".gz") && idx > maxRaw)
|
|
604
|
+
maxRaw = idx;
|
|
605
|
+
}
|
|
606
|
+
let idx = maxRaw >= 0 ? maxRaw : maxAny >= 0 ? maxAny + 1 : 0;
|
|
607
|
+
// ◀ TS FIX: local const narrows away null
|
|
608
|
+
const ai = this._audit.activeIndex;
|
|
609
|
+
if (this._auditEnabled &&
|
|
610
|
+
ai !== null &&
|
|
611
|
+
this._audit.activeStamp === stamp) {
|
|
612
|
+
idx = Math.max(idx, ai);
|
|
613
|
+
}
|
|
614
|
+
return idx;
|
|
615
|
+
}
|
|
616
|
+
async _ensureOpen() {
|
|
617
|
+
if (this._ws)
|
|
618
|
+
return;
|
|
619
|
+
const p = this._resolveName(this._curStamp, this._index);
|
|
620
|
+
this._bytes = statSizeSafe(p);
|
|
621
|
+
const ws = fs.createWriteStream(p, { flags: "a" });
|
|
622
|
+
ws.on("error", (e) => this.emit("error", e));
|
|
623
|
+
this._ws = ws;
|
|
624
|
+
this._path = p;
|
|
625
|
+
if (this._auditEnabled) {
|
|
626
|
+
const b = path.basename(p);
|
|
627
|
+
const prev = this._audit.files[b];
|
|
628
|
+
this._audit.files[b] = {
|
|
629
|
+
...(prev && prev.state === "archived" ? {} : prev),
|
|
630
|
+
index: this._index,
|
|
631
|
+
state: "active",
|
|
632
|
+
gzipPending: false,
|
|
633
|
+
openedAt: new Date().toISOString(),
|
|
634
|
+
};
|
|
635
|
+
this._setActivePointer(this._index);
|
|
636
|
+
this._aevent("open", {
|
|
637
|
+
file: b,
|
|
638
|
+
index: this._index,
|
|
639
|
+
resumedBytes: this._bytes,
|
|
640
|
+
});
|
|
641
|
+
this._saveAudit();
|
|
642
|
+
}
|
|
643
|
+
this.emit("open", p);
|
|
644
|
+
}
|
|
645
|
+
async _rotate(kind) {
|
|
646
|
+
const oldPath = this._path;
|
|
647
|
+
const oldWs = this._ws;
|
|
648
|
+
this._ws = null;
|
|
649
|
+
this._path = null;
|
|
650
|
+
this._bytes = 0;
|
|
651
|
+
let archive = null;
|
|
652
|
+
// ◀ TS FIX: guard on oldPath too — narrows string|null for the whole block,
|
|
653
|
+
// removing every `!` and the null-concatenation risk
|
|
654
|
+
if (oldWs && oldPath) {
|
|
655
|
+
await endStream(oldWs);
|
|
656
|
+
const real = statSizeSafe(oldPath);
|
|
657
|
+
const abase = path.basename(oldPath);
|
|
658
|
+
const arec = this._auditEnabled ? this._audit.files[abase] : null;
|
|
659
|
+
if (this.zippedArchive && real > 0) {
|
|
660
|
+
archive = oldPath + ".gz";
|
|
661
|
+
if (arec) {
|
|
662
|
+
arec.gzipPending = true;
|
|
663
|
+
arec.bytes = real;
|
|
664
|
+
this._saveAudit();
|
|
665
|
+
}
|
|
666
|
+
const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
|
|
667
|
+
if (ok) {
|
|
668
|
+
this._finishArchivedRecord(abase);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
if (arec) {
|
|
672
|
+
arec.gzipPending = false;
|
|
673
|
+
arec.state = "kept-raw";
|
|
674
|
+
}
|
|
675
|
+
this._aevent("gzip-failed", { file: abase });
|
|
676
|
+
archive = null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
else if (real === 0) {
|
|
680
|
+
try {
|
|
681
|
+
fs.unlinkSync(oldPath);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
/* ignore */
|
|
685
|
+
}
|
|
686
|
+
if (arec)
|
|
687
|
+
delete this._audit.files[abase];
|
|
688
|
+
this._aevent("drop-empty", { file: abase });
|
|
689
|
+
}
|
|
690
|
+
else if (arec) {
|
|
691
|
+
arec.state = "sealed";
|
|
692
|
+
arec.bytes = real;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const s = this._stampNow();
|
|
696
|
+
const newPeriod = s !== this._curStamp;
|
|
697
|
+
this._curStamp = s;
|
|
698
|
+
this._index = newPeriod
|
|
699
|
+
? this._recoverIndexFor(s)
|
|
700
|
+
: kind === "time"
|
|
701
|
+
? this._recoverIndexFor(s)
|
|
702
|
+
: this._index + 1;
|
|
703
|
+
this._bytes = 0;
|
|
704
|
+
if (this._auditEnabled) {
|
|
705
|
+
this._setActivePointer(this._index);
|
|
706
|
+
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp: s });
|
|
707
|
+
this._saveAudit();
|
|
708
|
+
}
|
|
709
|
+
if (kind === "time" || kind === "manual")
|
|
710
|
+
this._armTimer();
|
|
711
|
+
if (oldPath)
|
|
712
|
+
this.emit("rotated", {
|
|
713
|
+
reason: kind,
|
|
714
|
+
oldFile: oldPath,
|
|
715
|
+
archive,
|
|
716
|
+
newFile: this._resolveName(s, this._index),
|
|
717
|
+
});
|
|
718
|
+
this._retire();
|
|
719
|
+
}
|
|
720
|
+
/* ---------------- timers ---------------- */
|
|
721
|
+
_hasContent() {
|
|
722
|
+
return this._ws
|
|
723
|
+
? this._bytes > 0 || statSizeSafe(this._path) > 0
|
|
724
|
+
: !!(this._path && statSizeSafe(this._path) > 0);
|
|
725
|
+
}
|
|
726
|
+
_armTimer() {
|
|
727
|
+
this._stopTimer();
|
|
728
|
+
if (this._ended)
|
|
729
|
+
return;
|
|
730
|
+
const now = this._now();
|
|
731
|
+
const next = this.freq.type === "daily"
|
|
732
|
+
? nextMidnight(this.tz, now)
|
|
733
|
+
: nextAligned(now, this.freq.ms);
|
|
734
|
+
this._timer = setTimeout(() => {
|
|
735
|
+
this._job(async () => {
|
|
736
|
+
const s = this._stampNow();
|
|
737
|
+
if ((this.freq.type === "daily" && s !== this._curStamp) ||
|
|
738
|
+
this.freq.type !== "daily") {
|
|
739
|
+
if (this._hasContent()) {
|
|
740
|
+
await this._rotate("time");
|
|
741
|
+
}
|
|
742
|
+
else if (s !== this._curStamp) {
|
|
743
|
+
this._curStamp = s;
|
|
744
|
+
this._index = this._recoverIndexFor(s);
|
|
745
|
+
if (this._auditEnabled) {
|
|
746
|
+
this._setActivePointer(this._index);
|
|
747
|
+
this._aevent("period", { stamp: s });
|
|
748
|
+
this._saveAudit();
|
|
749
|
+
}
|
|
750
|
+
this.emit("period", s);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
this._armTimer();
|
|
754
|
+
}).catch(() => { });
|
|
755
|
+
}, Math.max(100, next - this._now()));
|
|
756
|
+
if (this.unrefTimers)
|
|
757
|
+
this._timer.unref();
|
|
758
|
+
}
|
|
759
|
+
_armBump() {
|
|
760
|
+
this._stopBump();
|
|
761
|
+
if (!this._stepMs || this._ended)
|
|
762
|
+
return;
|
|
763
|
+
this._bumpTimer = setInterval(() => this._job(() => this._applyBump()).catch(() => { }), this._bumpEvery);
|
|
764
|
+
if (this.unrefTimers)
|
|
765
|
+
this._bumpTimer.unref();
|
|
766
|
+
}
|
|
767
|
+
_stopBump() {
|
|
768
|
+
if (this._bumpTimer) {
|
|
769
|
+
clearInterval(this._bumpTimer);
|
|
770
|
+
this._bumpTimer = null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async _applyBump() {
|
|
774
|
+
if (this._ended)
|
|
775
|
+
return;
|
|
776
|
+
this._offsetMs += this._stepMs;
|
|
777
|
+
this._armTimer();
|
|
778
|
+
const s = this._stampNow();
|
|
779
|
+
if (s !== this._curStamp) {
|
|
780
|
+
if (this._hasContent()) {
|
|
781
|
+
await this._rotate("time");
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
this._curStamp = s;
|
|
785
|
+
this._index = this._recoverIndexFor(s);
|
|
786
|
+
if (this._auditEnabled) {
|
|
787
|
+
this._setActivePointer(this._index);
|
|
788
|
+
this._aevent("period", { stamp: s, virtual: true });
|
|
789
|
+
}
|
|
790
|
+
this.emit("period", s);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (this._auditEnabled) {
|
|
794
|
+
this._aevent("clock-step", {
|
|
795
|
+
addedMsPerMin: this._stepMs,
|
|
796
|
+
offsetMs: this._offsetMs,
|
|
797
|
+
stamp: s,
|
|
798
|
+
});
|
|
799
|
+
this._saveAudit();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
_stopTimer() {
|
|
803
|
+
if (this._timer) {
|
|
804
|
+
clearTimeout(this._timer);
|
|
805
|
+
this._timer = null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/* ---------------- retention ---------------- */
|
|
809
|
+
async _retire() {
|
|
810
|
+
if (!this.maxFiles)
|
|
811
|
+
return;
|
|
812
|
+
const activeBase = this._path && path.basename(this._path);
|
|
813
|
+
const activeStem = activeBase && activeBase.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
814
|
+
const auditNames = [
|
|
815
|
+
path.basename(this._auditPath),
|
|
816
|
+
path.basename(this._auditPath) + ".tmp",
|
|
817
|
+
path.basename(this._auditPath) + ".corrupt",
|
|
818
|
+
];
|
|
819
|
+
try {
|
|
820
|
+
const names = (await fs.promises.readdir(this.dir)).filter((n) => this._rxActive.test(n) &&
|
|
821
|
+
n !== activeBase &&
|
|
822
|
+
!(activeStem && n.startsWith(activeStem)) &&
|
|
823
|
+
!auditNames.includes(n));
|
|
824
|
+
const info = [];
|
|
825
|
+
for (const n of names) {
|
|
826
|
+
const full = path.join(this.dir, n);
|
|
827
|
+
try {
|
|
828
|
+
info.push({ full, mt: (await fs.promises.stat(full)).mtimeMs });
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
/* ignore */
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
info.sort((a, b) => a.mt - b.mt);
|
|
835
|
+
const kill = new Set();
|
|
836
|
+
if (this.maxFiles.days != null) {
|
|
837
|
+
const cut = this._now() - this.maxFiles.days * 86400000;
|
|
838
|
+
for (const f of info)
|
|
839
|
+
if (f.mt < cut)
|
|
840
|
+
kill.add(f.full);
|
|
841
|
+
}
|
|
842
|
+
if (this.maxFiles.count != null) {
|
|
843
|
+
const rest = info.filter((f) => !kill.has(f.full));
|
|
844
|
+
for (const f of rest.slice(0, Math.max(0, rest.length - this.maxFiles.count)))
|
|
845
|
+
kill.add(f.full);
|
|
846
|
+
}
|
|
847
|
+
let removed = false;
|
|
848
|
+
for (const f of kill) {
|
|
849
|
+
await fs.promises.unlink(f).catch(() => { });
|
|
850
|
+
if (this._auditEnabled) {
|
|
851
|
+
delete this._audit.files[path.basename(f)];
|
|
852
|
+
this._aevent("deleted", { file: path.basename(f) });
|
|
853
|
+
}
|
|
854
|
+
this.emit("deleted", f);
|
|
855
|
+
removed = true;
|
|
856
|
+
}
|
|
857
|
+
if (removed)
|
|
858
|
+
this._saveAudit();
|
|
859
|
+
}
|
|
860
|
+
catch (e) {
|
|
861
|
+
this.emit("warn", e);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/* ================================================================
|
|
866
|
+
* Factory
|
|
867
|
+
* ================================================================ */
|
|
868
|
+
function createStream(options) {
|
|
869
|
+
return new RotateFileStream(options);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export { RotateFileStream, createStream };
|