logroller 2.0.2 → 2.1.1
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/README.md +232 -46
- package/dist/fsio.d.ts +3 -3
- package/dist/index.cjs +2 -1080
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +2 -1077
- package/dist/parsers.d.ts +2 -0
- package/dist/rotate-file-stream.d.ts +36 -4
- package/dist/shutdown.d.ts +32 -0
- package/dist/types.d.ts +96 -66
- package/package.json +4 -4
- package/CHANGELOG.md +0 -133
package/dist/index.esm.js
CHANGED
|
@@ -1,1077 +1,2 @@
|
|
|
1
|
-
/*! logroller v2.
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { Writable } from 'node:stream';
|
|
6
|
-
import zlib from 'node:zlib';
|
|
7
|
-
import { pipeline } from 'node:stream/promises';
|
|
8
|
-
|
|
9
|
-
/** `stat().size` guarded to 0; string paths only. */
|
|
10
|
-
const statSizeSafe = (p) => {
|
|
11
|
-
try {
|
|
12
|
-
return typeof p === "string" ? fs.statSync(p).size : 0;
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
return 0;
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
const existsSafe = (p) => {
|
|
19
|
-
try {
|
|
20
|
-
return fs.existsSync(p);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* Compress `src` → `dst`, deleting `src` ONLY on verified success.
|
|
28
|
-
* On failure removes the partial `dst` and reports via callback —
|
|
29
|
-
* logs degrade to uncompressed rather than losing data.
|
|
30
|
-
*/
|
|
31
|
-
async function gzipMove(src, dst, onWarn) {
|
|
32
|
-
try {
|
|
33
|
-
await pipeline(fs.createReadStream(src), zlib.createGzip({ level: 6 }), fs.createWriteStream(dst));
|
|
34
|
-
await fs.promises.unlink(src);
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
await fs.promises.unlink(dst).catch(() => { });
|
|
39
|
-
onWarn?.(err);
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
const endStream = (ws) => ws ? new Promise((resolve) => ws.end(() => resolve())) : Promise.resolve();
|
|
44
|
-
|
|
45
|
-
const UNITS = {
|
|
46
|
-
b: 1, k: 1024, kb: 1024,
|
|
47
|
-
m: 1048576, mb: 1048576,
|
|
48
|
-
g: 1073741824, gb: 1073741824,
|
|
49
|
-
};
|
|
50
|
-
const FREQ_MULT = {
|
|
51
|
-
ms: 1, s: 1000, m: 60_000, h: 3_600_000,
|
|
52
|
-
};
|
|
53
|
-
function parseDuration(value, defaultUnit, fieldName) {
|
|
54
|
-
if (typeof value === "number") {
|
|
55
|
-
return Math.round(value * FREQ_MULT[defaultUnit]);
|
|
56
|
-
}
|
|
57
|
-
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
|
|
58
|
-
if (!m)
|
|
59
|
-
throw new Error(`Invalid ${fieldName} "${value}"`);
|
|
60
|
-
return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? defaultUnit).toLowerCase()]);
|
|
61
|
-
}
|
|
62
|
-
function parseSize(v) {
|
|
63
|
-
if (v === undefined || v === null || v === 0)
|
|
64
|
-
return 0;
|
|
65
|
-
if (typeof v === "number")
|
|
66
|
-
return v;
|
|
67
|
-
const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
|
|
68
|
-
if (!m)
|
|
69
|
-
throw new Error(`Invalid maxSize "${v}"`);
|
|
70
|
-
return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
|
|
71
|
-
}
|
|
72
|
-
function parseMaxFiles(v) {
|
|
73
|
-
if (v === undefined || v === null || v === "" || v === 0)
|
|
74
|
-
return null;
|
|
75
|
-
if (typeof v === "number")
|
|
76
|
-
return { count: v };
|
|
77
|
-
const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
|
|
78
|
-
if (m)
|
|
79
|
-
return { days: Number.parseInt(m[1], 10) };
|
|
80
|
-
const n = /^\s*(\d+)\s*$/.exec(v);
|
|
81
|
-
if (n)
|
|
82
|
-
return { count: Number.parseInt(n[1], 10) };
|
|
83
|
-
throw new Error(`Invalid maxFiles "${v}"`);
|
|
84
|
-
}
|
|
85
|
-
function parseFrequency(v) {
|
|
86
|
-
if (v === undefined || v === null)
|
|
87
|
-
return { type: "daily" };
|
|
88
|
-
if (v === "daily")
|
|
89
|
-
return { type: "daily" };
|
|
90
|
-
if (typeof v === "number") {
|
|
91
|
-
if (v <= 0)
|
|
92
|
-
throw new Error(`Invalid frequency "${v}"`);
|
|
93
|
-
return { type: "interval", ms: v };
|
|
94
|
-
}
|
|
95
|
-
const m = /^\s*(\d+)\s*(ms|s|m|h)\s*$/i.exec(v);
|
|
96
|
-
if (!m)
|
|
97
|
-
throw new Error(`Invalid frequency "${v}"`);
|
|
98
|
-
return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
|
|
99
|
-
}
|
|
100
|
-
function parseClockOffset(v) {
|
|
101
|
-
if (v === undefined || v === null || v === "")
|
|
102
|
-
return 0;
|
|
103
|
-
return parseDuration(v, "h", "addHours");
|
|
104
|
-
}
|
|
105
|
-
function parseClockStep(v) {
|
|
106
|
-
if (v === undefined || v === null || v === "")
|
|
107
|
-
return 0;
|
|
108
|
-
return parseDuration(v, "h", "addHours");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/* Timezone-aware helpers built exclusively on Intl (zero deps). */
|
|
112
|
-
const _dtfCache = new Map();
|
|
113
|
-
/** Cached `Intl.DateTimeFormat`; throws synchronously for invalid zones. */
|
|
114
|
-
function getDtf(tz) {
|
|
115
|
-
let dtf = _dtfCache.get(tz);
|
|
116
|
-
if (!dtf) {
|
|
117
|
-
try {
|
|
118
|
-
dtf = new Intl.DateTimeFormat("en-US", {
|
|
119
|
-
timeZone: tz,
|
|
120
|
-
hourCycle: "h23",
|
|
121
|
-
year: "numeric",
|
|
122
|
-
month: "2-digit",
|
|
123
|
-
day: "2-digit",
|
|
124
|
-
hour: "2-digit",
|
|
125
|
-
minute: "2-digit",
|
|
126
|
-
second: "2-digit",
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
throw new Error(`Invalid timeZone "${tz}"`);
|
|
131
|
-
}
|
|
132
|
-
_dtfCache.set(tz, dtf);
|
|
133
|
-
}
|
|
134
|
-
return dtf;
|
|
135
|
-
}
|
|
136
|
-
function partsIn(tz, date) {
|
|
137
|
-
const o = {};
|
|
138
|
-
for (const p of getDtf(tz).formatToParts(date))
|
|
139
|
-
o[p.type] = p.value;
|
|
140
|
-
return o;
|
|
141
|
-
}
|
|
142
|
-
const pad2 = (n) => n.padStart(2, "0");
|
|
143
|
-
/** Escape a literal string for embedding inside a RegExp. */
|
|
144
|
-
function escRe(s) {
|
|
145
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
146
|
-
}
|
|
147
|
-
/** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
|
|
148
|
-
function formatStamp(pattern, parts) {
|
|
149
|
-
return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
|
|
150
|
-
switch (token) {
|
|
151
|
-
case "YYYY":
|
|
152
|
-
return parts.year;
|
|
153
|
-
case "YY":
|
|
154
|
-
return parts.year.slice(-2);
|
|
155
|
-
case "MM":
|
|
156
|
-
return pad2(parts.month);
|
|
157
|
-
case "DD":
|
|
158
|
-
return pad2(parts.day);
|
|
159
|
-
case "HH":
|
|
160
|
-
return pad2(parts.hour);
|
|
161
|
-
case "mm":
|
|
162
|
-
return pad2(parts.minute);
|
|
163
|
-
case "ss":
|
|
164
|
-
return pad2(parts.second);
|
|
165
|
-
default:
|
|
166
|
-
return token;
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
/** Offset of `tz` vs UTC at instant `date`, milliseconds. */
|
|
171
|
-
function tzOffsetMs(tz, date) {
|
|
172
|
-
const p = partsIn(tz, date);
|
|
173
|
-
return (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) -
|
|
174
|
-
(date.getTime() - (date.getTime() % 1000)));
|
|
175
|
-
}
|
|
176
|
-
/** Next local midnight in `tz`, DST-corrected by fixed-point iteration. */
|
|
177
|
-
function nextMidnight(tz, fromMs) {
|
|
178
|
-
const p = partsIn(tz, new Date(fromMs));
|
|
179
|
-
const localNext = Date.UTC(+p.year, +p.month - 1, +p.day + 1);
|
|
180
|
-
let inst = localNext - tzOffsetMs(tz, new Date(localNext));
|
|
181
|
-
for (let i = 0; i < 3; i++) {
|
|
182
|
-
const cand = localNext - tzOffsetMs(tz, new Date(inst));
|
|
183
|
-
if (cand === inst)
|
|
184
|
-
break;
|
|
185
|
-
inst = cand;
|
|
186
|
-
}
|
|
187
|
-
if (inst <= fromMs)
|
|
188
|
-
inst += 86_400_000;
|
|
189
|
-
return inst;
|
|
190
|
-
}
|
|
191
|
-
/** Next epoch-aligned boundary (e.g. hh:00 for step = 1h). */
|
|
192
|
-
const nextAligned = (from, step) => Math.floor(from / step) * step + step;
|
|
193
|
-
|
|
194
|
-
const BUMP_DEFAULT_MS = 60_000;
|
|
195
|
-
const AUDIT_MAX_FILES = 600;
|
|
196
|
-
const AUDIT_MAX_EVENTS = 200;
|
|
197
|
-
const HEARTBEAT_STALE_MS = 30_000;
|
|
198
|
-
const TIMER_POLL_MS = 1_000;
|
|
199
|
-
const emptyAudit = (prefix, ext, datePattern, tz) => ({
|
|
200
|
-
version: 1,
|
|
201
|
-
prefix,
|
|
202
|
-
ext,
|
|
203
|
-
datePattern,
|
|
204
|
-
tz,
|
|
205
|
-
createdAt: null,
|
|
206
|
-
updatedAt: null,
|
|
207
|
-
activeStamp: null,
|
|
208
|
-
activeIndex: null,
|
|
209
|
-
writer: null,
|
|
210
|
-
files: {},
|
|
211
|
-
events: [],
|
|
212
|
-
});
|
|
213
|
-
/**
|
|
214
|
-
* 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.
|
|
218
|
-
*
|
|
219
|
-
* @example
|
|
220
|
-
* ```ts
|
|
221
|
-
* const log = createStream({
|
|
222
|
-
* filename: "logs/app-%DATE%.log",
|
|
223
|
-
* maxSize: "20m",
|
|
224
|
-
* maxFiles: "90d",
|
|
225
|
-
* zippedArchive: true,
|
|
226
|
-
* });
|
|
227
|
-
* log.write("hello\n"); // or: readable.pipe(log)
|
|
228
|
-
* ```
|
|
229
|
-
*/
|
|
230
|
-
class RotateFileStream extends Writable {
|
|
231
|
-
tz;
|
|
232
|
-
template;
|
|
233
|
-
dir;
|
|
234
|
-
datePattern;
|
|
235
|
-
zippedArchive;
|
|
236
|
-
maxSize;
|
|
237
|
-
maxFiles;
|
|
238
|
-
freq;
|
|
239
|
-
unrefTimers;
|
|
240
|
-
/* ---- filename-template pieces ---- */
|
|
241
|
-
_tL;
|
|
242
|
-
_tMid;
|
|
243
|
-
_tExt;
|
|
244
|
-
_rxFor;
|
|
245
|
-
_rxActive;
|
|
246
|
-
/* ---- audit manifest ---- */
|
|
247
|
-
_auditEnabled;
|
|
248
|
-
_auditPath;
|
|
249
|
-
_bootId = randomUUID();
|
|
250
|
-
/* ---- virtual test clock ---- */
|
|
251
|
-
_offsetMs;
|
|
252
|
-
_stepMs;
|
|
253
|
-
_bumpEvery;
|
|
254
|
-
/* ---- live state ---- */
|
|
255
|
-
_curStamp;
|
|
256
|
-
_index;
|
|
257
|
-
_ws = null;
|
|
258
|
-
_path = null;
|
|
259
|
-
_bytes = 0;
|
|
260
|
-
_timer = null;
|
|
261
|
-
_bumpTimer = null;
|
|
262
|
-
_ended = false;
|
|
263
|
-
_tail = Promise.resolve();
|
|
264
|
-
_warnedBackward = false;
|
|
265
|
-
_audit;
|
|
266
|
-
constructor(opts = {}) {
|
|
267
|
-
super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
|
|
268
|
-
const filename = opts.filename;
|
|
269
|
-
if (typeof filename !== "string" || !filename.includes("%DATE%")) {
|
|
270
|
-
throw new Error("opts.filename must contain '%DATE%' (e.g. 'logs/app-%DATE%.log')");
|
|
271
|
-
}
|
|
272
|
-
this.tz = opts.tz || "UTC";
|
|
273
|
-
getDtf(this.tz); // validate early, fail fast
|
|
274
|
-
this.template = path.basename(filename);
|
|
275
|
-
this.dir = path.dirname(filename);
|
|
276
|
-
this.datePattern = opts.datePattern || "YYYY-MM-DD";
|
|
277
|
-
this.zippedArchive = !!opts.zippedArchive;
|
|
278
|
-
this.maxSize = parseSize(opts.maxSize ?? 0);
|
|
279
|
-
this.maxFiles = parseMaxFiles(opts.maxFiles);
|
|
280
|
-
this.freq = parseFrequency(opts.frequency);
|
|
281
|
-
this.unrefTimers = !!opts.unrefTimers;
|
|
282
|
-
this._offsetMs = parseClockOffset(opts.addHours);
|
|
283
|
-
this._stepMs = parseClockStep(opts.addHoursEveryMin);
|
|
284
|
-
this._bumpEvery = Math.max(50, opts.clockStepIntervalMs ?? BUMP_DEFAULT_MS);
|
|
285
|
-
/* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
|
|
286
|
-
stamp and the extension: prefix-STAMP.N.ext */
|
|
287
|
-
const li = this.template.indexOf("%DATE%");
|
|
288
|
-
const rest = this.template.slice(li + "%DATE%".length);
|
|
289
|
-
const dot = rest.lastIndexOf(".");
|
|
290
|
-
this._tL = this.template.slice(0, li);
|
|
291
|
-
this._tMid = dot >= 0 ? rest.slice(0, dot) : "";
|
|
292
|
-
this._tExt = dot >= 0 ? rest.slice(dot) : "";
|
|
293
|
-
this._rxFor = (stampSrc) => new RegExp("^" +
|
|
294
|
-
escRe(this._tL) + stampSrc + escRe(this._tMid) +
|
|
295
|
-
"(?:\\.(\\d+))?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
296
|
-
this._rxActive = this._rxFor("[^.]+");
|
|
297
|
-
this._auditEnabled = opts.audit !== false;
|
|
298
|
-
const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
|
|
299
|
-
.replace(/^[-.]+|[-.]+$/g, "");
|
|
300
|
-
this._auditPath = path.join(this.dir, opts.auditFile || `${stem}_audit.json`);
|
|
301
|
-
fs.mkdirSync(this.dir, { recursive: true });
|
|
302
|
-
this._curStamp = this._stampNow();
|
|
303
|
-
this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
|
|
304
|
-
const hadAudit = this._auditEnabled && this._loadAudit();
|
|
305
|
-
this._index = this._recoverIndexFor(this._curStamp);
|
|
306
|
-
const aiBoot = this._audit.activeIndex;
|
|
307
|
-
if (hadAudit && aiBoot !== null && this._audit.activeStamp === this._curStamp) {
|
|
308
|
-
this._index = Math.max(this._index, aiBoot);
|
|
309
|
-
}
|
|
310
|
-
if (this._offsetMs !== 0 || this._stepMs !== 0) {
|
|
311
|
-
process.nextTick(() => this.emit("clock", {
|
|
312
|
-
addHours: opts.addHours ?? undefined,
|
|
313
|
-
addHoursEveryMin: opts.addHoursEveryMin ?? undefined,
|
|
314
|
-
offsetMs: this._offsetMs,
|
|
315
|
-
}));
|
|
316
|
-
}
|
|
317
|
-
process.nextTick(() => {
|
|
318
|
-
const warn = (m) => this.emit("warn", new Error(m));
|
|
319
|
-
if (this.freq.type === "interval") {
|
|
320
|
-
if (this.freq.ms < 86_400_000 && !this.datePattern.includes("HH")) {
|
|
321
|
-
warn(`frequency "${opts.frequency}" needs HH in datePattern — ` +
|
|
322
|
-
"segments would collapse onto one filename");
|
|
323
|
-
}
|
|
324
|
-
if (this.freq.ms < 60_000 && !this.datePattern.includes("mm")) {
|
|
325
|
-
warn(`frequency "${opts.frequency}" needs mm in datePattern`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
else if (!this.datePattern.includes("DD")) {
|
|
329
|
-
warn("daily frequency needs DD in datePattern");
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
/* Boot housekeeping is job #1 of the serial queue — chained HERE in the
|
|
333
|
-
constructor so it deterministically precedes any write job: zombie
|
|
334
|
-
demotion sees `_path === null`, gzip repair cannot race a reopen. */
|
|
335
|
-
this._job(async () => {
|
|
336
|
-
await this._sealOrphanedPastStamps();
|
|
337
|
-
if (this._auditEnabled) {
|
|
338
|
-
this._demoteZombieActives();
|
|
339
|
-
this._reconcileWithDisk();
|
|
340
|
-
await this._repairInterruptedGzips();
|
|
341
|
-
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
342
|
-
this._saveAudit();
|
|
343
|
-
}
|
|
344
|
-
if (this.maxFiles)
|
|
345
|
-
this._retire();
|
|
346
|
-
}).catch(() => { });
|
|
347
|
-
this._armTimer();
|
|
348
|
-
this._armBump();
|
|
349
|
-
this.once("close", () => {
|
|
350
|
-
this._stopTimer();
|
|
351
|
-
this._stopBump();
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
on(event, listener) {
|
|
355
|
-
return super.on(event, listener);
|
|
356
|
-
}
|
|
357
|
-
once(event, listener) {
|
|
358
|
-
return super.once(event, listener);
|
|
359
|
-
}
|
|
360
|
-
emit(event, ...args) {
|
|
361
|
-
return super.emit(event, ...args);
|
|
362
|
-
}
|
|
363
|
-
/* ==================================================================
|
|
364
|
-
* Public API
|
|
365
|
-
* ================================================================ */
|
|
366
|
-
/** Absolute path of the segment currently written; `null` until first write. */
|
|
367
|
-
get filename() {
|
|
368
|
-
return this._path;
|
|
369
|
-
}
|
|
370
|
-
/** Index of the active segment within the current period. */
|
|
371
|
-
get currentIndex() {
|
|
372
|
-
return this._index;
|
|
373
|
-
}
|
|
374
|
-
/** Force an immediate rotation and resolve when the next slot is staged. */
|
|
375
|
-
rotateNow() {
|
|
376
|
-
return this._job(() => this._rotate("manual"));
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Apply ONE virtual-clock tick immediately (bypasses the interval timer
|
|
380
|
-
* for deterministic tests). Crossing into a new period triggers the same
|
|
381
|
-
* archive flow as a real midnight roll.
|
|
382
|
-
*/
|
|
383
|
-
advanceClock() {
|
|
384
|
-
return this._job(() => this._applyBump());
|
|
385
|
-
}
|
|
386
|
-
/* ==================================================================
|
|
387
|
-
* Writable contract
|
|
388
|
-
* ================================================================ */
|
|
389
|
-
_write(chunk, encoding, callback) {
|
|
390
|
-
if (this._ended) {
|
|
391
|
-
process.nextTick(callback, new Error("write after end"));
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
const len = chunk.length;
|
|
395
|
-
this._job(async () => {
|
|
396
|
-
try {
|
|
397
|
-
// guarded roll (never rotates into a regressed stamp)
|
|
398
|
-
if (this._stampAdvanced(this._stampNow()))
|
|
399
|
-
await this._rotate("time");
|
|
400
|
-
await this._ensureOpen();
|
|
401
|
-
if (this.maxSize > 0 &&
|
|
402
|
-
this._bytes > 0 &&
|
|
403
|
-
this._bytes + len > this.maxSize) {
|
|
404
|
-
await this._rotate("size");
|
|
405
|
-
await this._ensureOpen();
|
|
406
|
-
}
|
|
407
|
-
const ws = this._ws;
|
|
408
|
-
if (!ws)
|
|
409
|
-
throw new Error("segment write stream not open");
|
|
410
|
-
await new Promise((resolve, reject) => ws.write(chunk, encoding, (e) => (e ? reject(e) : resolve())));
|
|
411
|
-
this._bytes += len;
|
|
412
|
-
callback();
|
|
413
|
-
}
|
|
414
|
-
catch (err) {
|
|
415
|
-
this.emit("error", err);
|
|
416
|
-
callback(err);
|
|
417
|
-
}
|
|
418
|
-
}).catch(() => { });
|
|
419
|
-
}
|
|
420
|
-
_final(callback) {
|
|
421
|
-
this._ended = true;
|
|
422
|
-
this._stopTimer();
|
|
423
|
-
this._stopBump();
|
|
424
|
-
this._job(async () => {
|
|
425
|
-
await endStream(this._ws);
|
|
426
|
-
const base = this._path && path.basename(this._path);
|
|
427
|
-
if (base && this._audit.files[base]) {
|
|
428
|
-
const rec = this._audit.files[base];
|
|
429
|
-
rec.state = "closed";
|
|
430
|
-
rec.closedAt = new Date().toISOString();
|
|
431
|
-
rec.bytes = statSizeSafe(this._path);
|
|
432
|
-
this._aevent("close", { file: base });
|
|
433
|
-
this._saveAudit();
|
|
434
|
-
}
|
|
435
|
-
this._ws = null;
|
|
436
|
-
this._path = null;
|
|
437
|
-
})
|
|
438
|
-
.catch(() => { })
|
|
439
|
-
.finally(() => callback());
|
|
440
|
-
}
|
|
441
|
-
destroy(...args) {
|
|
442
|
-
this._stopTimer();
|
|
443
|
-
this._stopBump();
|
|
444
|
-
return super.destroy(...args);
|
|
445
|
-
}
|
|
446
|
-
/* ==================================================================
|
|
447
|
-
* Serialisation — every mutating operation funnels through one queue
|
|
448
|
-
* so rotations/gzips/writes never interleave.
|
|
449
|
-
* ================================================================ */
|
|
450
|
-
_job(fn) {
|
|
451
|
-
const run = this._tail.then(fn, fn);
|
|
452
|
-
this._tail = run.catch(() => { });
|
|
453
|
-
return run;
|
|
454
|
-
}
|
|
455
|
-
_now() {
|
|
456
|
-
return Date.now() + this._offsetMs;
|
|
457
|
-
}
|
|
458
|
-
_stampNow(d = new Date(this._now())) {
|
|
459
|
-
return formatStamp(this.datePattern, partsIn(this.tz, d));
|
|
460
|
-
}
|
|
461
|
-
_resolveName(stamp, index) {
|
|
462
|
-
const base = path.join(this.dir, this._tL + stamp + this._tMid + this._tExt);
|
|
463
|
-
if (!index)
|
|
464
|
-
return base;
|
|
465
|
-
return /\.[^.]+$/.test(base)
|
|
466
|
-
? base.replace(/\.[^.]+$/, `.${index}$&`)
|
|
467
|
-
: `${base}.${index}`;
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* true only when the period legally advanced. A regressed
|
|
471
|
-
* clock (NTP correction, manual change) never reopens a past family:
|
|
472
|
-
* writes continue in the current segment until real time catches up.
|
|
473
|
-
* Requires zero-padded datePattern tokens (documented assumption).
|
|
474
|
-
*/
|
|
475
|
-
_stampAdvanced(stamp) {
|
|
476
|
-
if (stamp > this._curStamp)
|
|
477
|
-
return true;
|
|
478
|
-
if (stamp < this._curStamp && !this._warnedBackward) {
|
|
479
|
-
this._warnedBackward = true;
|
|
480
|
-
this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
|
|
481
|
-
"keeping current segment until the clock catches up"));
|
|
482
|
-
}
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Highest viable segment index for `stamp`.
|
|
487
|
-
* Disk is truth: resume a raw tail; start after sealed ones.
|
|
488
|
-
* The audit pointer (same stamp only) may only RAISE the result.
|
|
489
|
-
*/
|
|
490
|
-
_recoverIndexFor(stamp) {
|
|
491
|
-
const rx = this._rxFor(escRe(stamp));
|
|
492
|
-
let maxAny = -1;
|
|
493
|
-
let maxRaw = -1;
|
|
494
|
-
let names = [];
|
|
495
|
-
try {
|
|
496
|
-
names = fs.readdirSync(this.dir);
|
|
497
|
-
}
|
|
498
|
-
catch {
|
|
499
|
-
/* unreadable dir → fresh series */
|
|
500
|
-
}
|
|
501
|
-
for (const n of names) {
|
|
502
|
-
const m = rx.exec(n);
|
|
503
|
-
if (!m)
|
|
504
|
-
continue;
|
|
505
|
-
const idx = m[1] ? Number.parseInt(m[1], 10) : 0;
|
|
506
|
-
if (idx > maxAny)
|
|
507
|
-
maxAny = idx;
|
|
508
|
-
if (!n.endsWith(".gz") && idx > maxRaw)
|
|
509
|
-
maxRaw = idx;
|
|
510
|
-
}
|
|
511
|
-
let idx = maxRaw >= 0 ? maxRaw : maxAny >= 0 ? maxAny + 1 : 0;
|
|
512
|
-
const ai = this._audit.activeIndex;
|
|
513
|
-
if (this._auditEnabled && ai !== null && this._audit.activeStamp === stamp) {
|
|
514
|
-
idx = Math.max(idx, ai);
|
|
515
|
-
}
|
|
516
|
-
return idx;
|
|
517
|
-
}
|
|
518
|
-
async _ensureOpen() {
|
|
519
|
-
if (this._ws)
|
|
520
|
-
return;
|
|
521
|
-
const p = this._resolveName(this._curStamp, this._index);
|
|
522
|
-
this._bytes = statSizeSafe(p);
|
|
523
|
-
const ws = fs.createWriteStream(p, { flags: "a" });
|
|
524
|
-
ws.on("error", (e) => this.emit("error", e));
|
|
525
|
-
this._ws = ws;
|
|
526
|
-
this._path = p;
|
|
527
|
-
if (this._auditEnabled) {
|
|
528
|
-
const base = path.basename(p);
|
|
529
|
-
// reopen = NEW incarnation. Never inherit lifecycle
|
|
530
|
-
// fields from the previous record (observed in the wild as
|
|
531
|
-
// abandonedAt < openedAt on reactivated segments).
|
|
532
|
-
this._audit.files[base] = {
|
|
533
|
-
index: this._index,
|
|
534
|
-
state: "active",
|
|
535
|
-
gzipPending: false,
|
|
536
|
-
openedAt: new Date().toISOString(),
|
|
537
|
-
};
|
|
538
|
-
this._setActivePointer(this._index);
|
|
539
|
-
this._aevent("open", {
|
|
540
|
-
file: base,
|
|
541
|
-
index: this._index,
|
|
542
|
-
resumedBytes: this._bytes,
|
|
543
|
-
});
|
|
544
|
-
this._saveAudit();
|
|
545
|
-
}
|
|
546
|
-
this.emit("open", p);
|
|
547
|
-
}
|
|
548
|
-
async _rotate(kind) {
|
|
549
|
-
const oldPath = this._path;
|
|
550
|
-
const oldWs = this._ws;
|
|
551
|
-
this._ws = null;
|
|
552
|
-
this._path = null;
|
|
553
|
-
this._bytes = 0;
|
|
554
|
-
let archive = null;
|
|
555
|
-
if (oldWs && oldPath) {
|
|
556
|
-
await endStream(oldWs);
|
|
557
|
-
const realBytes = statSizeSafe(oldPath);
|
|
558
|
-
const oldBase = path.basename(oldPath);
|
|
559
|
-
const rec = this._auditEnabled ? this._audit.files[oldBase] : undefined;
|
|
560
|
-
if (this.zippedArchive && realBytes > 0) {
|
|
561
|
-
archive = `${oldPath}.gz`;
|
|
562
|
-
/* Mark intent BEFORE compressing: crash mid-gzip becomes a
|
|
563
|
-
repairable `gzipPending` flag instead of silent divergence. */
|
|
564
|
-
if (rec) {
|
|
565
|
-
rec.gzipPending = true;
|
|
566
|
-
rec.bytes = realBytes;
|
|
567
|
-
this._saveAudit();
|
|
568
|
-
}
|
|
569
|
-
const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
|
|
570
|
-
if (ok) {
|
|
571
|
-
this._finishArchivedRecord(oldBase);
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
if (rec) {
|
|
575
|
-
rec.gzipPending = false;
|
|
576
|
-
rec.state = "kept-raw";
|
|
577
|
-
}
|
|
578
|
-
this._aevent("gzip-failed", { file: oldBase });
|
|
579
|
-
archive = null;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
else if (realBytes === 0) {
|
|
583
|
-
// a concurrent writer may have archived this segment
|
|
584
|
-
// between our last stat and now — adopt its artifact instead of
|
|
585
|
-
// recording a false `drop-empty` that orphans the .gz.
|
|
586
|
-
const adoptedGz = `${oldPath}.gz`;
|
|
587
|
-
if (existsSafe(adoptedGz)) {
|
|
588
|
-
this._finishArchivedRecord(oldBase);
|
|
589
|
-
archive = adoptedGz;
|
|
590
|
-
}
|
|
591
|
-
else {
|
|
592
|
-
try {
|
|
593
|
-
fs.unlinkSync(oldPath);
|
|
594
|
-
}
|
|
595
|
-
catch {
|
|
596
|
-
/* already gone */
|
|
597
|
-
}
|
|
598
|
-
if (rec)
|
|
599
|
-
delete this._audit.files[oldBase];
|
|
600
|
-
this._aevent("drop-empty", { file: oldBase });
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
else if (rec) {
|
|
604
|
-
rec.state = "sealed";
|
|
605
|
-
rec.bytes = realBytes;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
let stamp = this._stampNow();
|
|
609
|
-
// a rotation itself must never move the series backward
|
|
610
|
-
// (covers rotateNow()/interval/size paths the upstream guards miss)
|
|
611
|
-
if (stamp < this._curStamp) {
|
|
612
|
-
if (!this._warnedBackward) {
|
|
613
|
-
this._warnedBackward = true;
|
|
614
|
-
this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
|
|
615
|
-
"rotation clamped to the current period"));
|
|
616
|
-
}
|
|
617
|
-
stamp = this._curStamp;
|
|
618
|
-
}
|
|
619
|
-
const newPeriod = stamp !== this._curStamp;
|
|
620
|
-
this._curStamp = stamp;
|
|
621
|
-
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
622
|
-
this._index =
|
|
623
|
-
newPeriod || kind === "time"
|
|
624
|
-
? this._recoverIndexFor(stamp)
|
|
625
|
-
: this._index + 1;
|
|
626
|
-
this._bytes = 0;
|
|
627
|
-
if (this._auditEnabled) {
|
|
628
|
-
this._setActivePointer(this._index);
|
|
629
|
-
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
|
|
630
|
-
this._saveAudit();
|
|
631
|
-
}
|
|
632
|
-
if (kind === "time" || kind === "manual")
|
|
633
|
-
this._armTimer();
|
|
634
|
-
if (oldPath) {
|
|
635
|
-
this.emit("rotated", {
|
|
636
|
-
reason: kind,
|
|
637
|
-
oldFile: oldPath,
|
|
638
|
-
archive,
|
|
639
|
-
newFile: this._resolveName(stamp, this._index),
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
this._retire();
|
|
643
|
-
}
|
|
644
|
-
/* ==================================================================
|
|
645
|
-
* Timers
|
|
646
|
-
* ================================================================ */
|
|
647
|
-
_hasContent() {
|
|
648
|
-
return this._ws
|
|
649
|
-
? this._bytes > 0 || statSizeSafe(this._path) > 0
|
|
650
|
-
: !!(this._path && statSizeSafe(this._path) > 0);
|
|
651
|
-
}
|
|
652
|
-
_armTimer() {
|
|
653
|
-
this._stopTimer();
|
|
654
|
-
if (this._ended)
|
|
655
|
-
return;
|
|
656
|
-
const now = this._now();
|
|
657
|
-
const next = this.freq.type === "daily"
|
|
658
|
-
? nextMidnight(this.tz, now)
|
|
659
|
-
: nextAligned(now, this.freq.ms);
|
|
660
|
-
// anti-spin. A backward clock can put the computed
|
|
661
|
-
// boundary in the past; poll gently instead of firing every 100 ms.
|
|
662
|
-
const delta = next - this._now();
|
|
663
|
-
const delay = delta < 0 ? TIMER_POLL_MS : Math.max(100, delta);
|
|
664
|
-
this._timer = setTimeout(() => {
|
|
665
|
-
this._job(async () => {
|
|
666
|
-
const stamp = this._stampNow();
|
|
667
|
-
if (this.freq.type !== "daily") {
|
|
668
|
-
/* interval rotation is schedule-based; runs regardless of
|
|
669
|
-
stamp movement */
|
|
670
|
-
if (this._hasContent())
|
|
671
|
-
await this._rotate("time");
|
|
672
|
-
}
|
|
673
|
-
else if (this._stampAdvanced(stamp)) {
|
|
674
|
-
// guarded daily branch
|
|
675
|
-
if (this._hasContent()) {
|
|
676
|
-
await this._rotate("time");
|
|
677
|
-
}
|
|
678
|
-
else {
|
|
679
|
-
/* idle boundary slide — no file created for empty days */
|
|
680
|
-
this._curStamp = stamp;
|
|
681
|
-
this._index = this._recoverIndexFor(stamp);
|
|
682
|
-
if (this._auditEnabled) {
|
|
683
|
-
this._setActivePointer(this._index);
|
|
684
|
-
this._aevent("period", { stamp });
|
|
685
|
-
this._saveAudit();
|
|
686
|
-
}
|
|
687
|
-
this.emit("period", stamp);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
/* stamp regressed or unchanged → keep current segment, re-arm */
|
|
691
|
-
this._armTimer();
|
|
692
|
-
}).catch(() => { });
|
|
693
|
-
}, delay);
|
|
694
|
-
if (this.unrefTimers)
|
|
695
|
-
this._timer.unref();
|
|
696
|
-
}
|
|
697
|
-
_armBump() {
|
|
698
|
-
this._stopBump();
|
|
699
|
-
if (!this._stepMs || this._ended)
|
|
700
|
-
return;
|
|
701
|
-
this._bumpTimer = setInterval(() => this._job(() => this._applyBump()).catch(() => { }), this._bumpEvery);
|
|
702
|
-
if (this.unrefTimers)
|
|
703
|
-
this._bumpTimer.unref();
|
|
704
|
-
}
|
|
705
|
-
_stopBump() {
|
|
706
|
-
if (this._bumpTimer) {
|
|
707
|
-
clearInterval(this._bumpTimer);
|
|
708
|
-
this._bumpTimer = null;
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
async _applyBump() {
|
|
712
|
-
if (this._ended)
|
|
713
|
-
return;
|
|
714
|
-
this._offsetMs += this._stepMs;
|
|
715
|
-
this._armTimer();
|
|
716
|
-
const stamp = this._stampNow();
|
|
717
|
-
// guarded crossing (virtual clock can't roll backward either)
|
|
718
|
-
if (this._stampAdvanced(stamp)) {
|
|
719
|
-
if (this._hasContent()) {
|
|
720
|
-
await this._rotate("time");
|
|
721
|
-
}
|
|
722
|
-
else {
|
|
723
|
-
this._curStamp = stamp;
|
|
724
|
-
this._index = this._recoverIndexFor(stamp);
|
|
725
|
-
if (this._auditEnabled) {
|
|
726
|
-
this._setActivePointer(this._index);
|
|
727
|
-
this._aevent("period", { stamp, virtual: true });
|
|
728
|
-
}
|
|
729
|
-
this.emit("period", stamp);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
if (this._auditEnabled) {
|
|
733
|
-
this._aevent("clock-step", {
|
|
734
|
-
addedMsPerMin: this._stepMs,
|
|
735
|
-
offsetMs: this._offsetMs,
|
|
736
|
-
stamp,
|
|
737
|
-
});
|
|
738
|
-
this._saveAudit();
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
_stopTimer() {
|
|
742
|
-
if (this._timer) {
|
|
743
|
-
clearTimeout(this._timer);
|
|
744
|
-
this._timer = null;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
/* ==================================================================
|
|
748
|
-
* Audit manifest
|
|
749
|
-
* ================================================================ */
|
|
750
|
-
_loadAudit() {
|
|
751
|
-
let parsed;
|
|
752
|
-
try {
|
|
753
|
-
parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
|
|
754
|
-
}
|
|
755
|
-
catch {
|
|
756
|
-
// unparseable manifest is quarantined too (was silently
|
|
757
|
-
// clobbered by the first save — caught by the quarantine test)
|
|
758
|
-
try {
|
|
759
|
-
fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
|
|
760
|
-
}
|
|
761
|
-
catch {
|
|
762
|
-
/* ignore */
|
|
763
|
-
}
|
|
764
|
-
return false;
|
|
765
|
-
}
|
|
766
|
-
const j = parsed;
|
|
767
|
-
if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
|
|
768
|
-
try {
|
|
769
|
-
fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
|
|
770
|
-
}
|
|
771
|
-
catch {
|
|
772
|
-
/* ignore */
|
|
773
|
-
}
|
|
774
|
-
return false;
|
|
775
|
-
}
|
|
776
|
-
this._audit.createdAt = j.createdAt ?? new Date().toISOString();
|
|
777
|
-
this._audit.activeStamp =
|
|
778
|
-
typeof j.activeStamp === "string" ? j.activeStamp : null;
|
|
779
|
-
this._audit.activeIndex =
|
|
780
|
-
Number.isInteger(j.activeIndex) ? j.activeIndex : null;
|
|
781
|
-
this._audit.files =
|
|
782
|
-
j.files && typeof j.files === "object"
|
|
783
|
-
? j.files
|
|
784
|
-
: {};
|
|
785
|
-
this._audit.events = Array.isArray(j.events)
|
|
786
|
-
? j.events.slice(-AUDIT_MAX_EVENTS)
|
|
787
|
-
: [];
|
|
788
|
-
/* Detect another LIVE process on this family before clobbering it. */
|
|
789
|
-
const w = j.writer;
|
|
790
|
-
if (w &&
|
|
791
|
-
typeof w.bootId === "string" &&
|
|
792
|
-
w.bootId !== this._bootId &&
|
|
793
|
-
typeof w.heartbeat === "number" &&
|
|
794
|
-
Date.now() - w.heartbeat < HEARTBEAT_STALE_MS) {
|
|
795
|
-
const err = new Error(`concurrent writer detected (pid ${w.pid}, boot ${w.bootId}); ` +
|
|
796
|
-
"two processes share this log family and will race. " +
|
|
797
|
-
"If running 'node --watch', exclude the logs dir via --watch-path.");
|
|
798
|
-
this.emit("warn", err);
|
|
799
|
-
this._audit.events.push({
|
|
800
|
-
id: randomUUID(),
|
|
801
|
-
t: new Date().toISOString(),
|
|
802
|
-
type: "concurrent-writer",
|
|
803
|
-
otherPid: w.pid,
|
|
804
|
-
otherBoot: w.bootId,
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
return true;
|
|
808
|
-
}
|
|
809
|
-
_saveAudit() {
|
|
810
|
-
if (!this._auditEnabled)
|
|
811
|
-
return;
|
|
812
|
-
try {
|
|
813
|
-
this._pruneAuditFiles();
|
|
814
|
-
this._audit.updatedAt = new Date().toISOString();
|
|
815
|
-
this._audit.writer = {
|
|
816
|
-
pid: process.pid,
|
|
817
|
-
bootId: this._bootId,
|
|
818
|
-
heartbeat: Date.now(),
|
|
819
|
-
};
|
|
820
|
-
const tmp = `${this._auditPath}.tmp`;
|
|
821
|
-
fs.writeFileSync(tmp, JSON.stringify(this._audit, null, 1));
|
|
822
|
-
fs.renameSync(tmp, this._auditPath);
|
|
823
|
-
}
|
|
824
|
-
catch (e) {
|
|
825
|
-
this.emit("warn", e);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
_pruneAuditFiles() {
|
|
829
|
-
const files = this._audit.files;
|
|
830
|
-
const keys = Object.keys(files);
|
|
831
|
-
if (keys.length <= AUDIT_MAX_FILES)
|
|
832
|
-
return;
|
|
833
|
-
const activeBase = this._path && path.basename(this._path);
|
|
834
|
-
const lastTouch = (r) => r.archivedAt ?? r.closedAt ?? r.abandonedAt ?? r.openedAt ?? "";
|
|
835
|
-
const stale = keys
|
|
836
|
-
.filter((k) => k !== activeBase && files[k].state !== "active")
|
|
837
|
-
.sort((a, b) => lastTouch(files[a]).localeCompare(lastTouch(files[b])));
|
|
838
|
-
const excess = keys.length - AUDIT_MAX_FILES + 50;
|
|
839
|
-
for (const key of stale.slice(0, excess))
|
|
840
|
-
delete files[key];
|
|
841
|
-
}
|
|
842
|
-
_aevent(type, extra = {}) {
|
|
843
|
-
if (!this._auditEnabled)
|
|
844
|
-
return;
|
|
845
|
-
const events = this._audit.events;
|
|
846
|
-
events.push({ id: randomUUID(), t: new Date().toISOString(), type, ...extra });
|
|
847
|
-
if (events.length > AUDIT_MAX_EVENTS) {
|
|
848
|
-
events.splice(0, events.length - AUDIT_MAX_EVENTS);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
_setActivePointer(index) {
|
|
852
|
-
this._audit.activeStamp = this._curStamp;
|
|
853
|
-
this._audit.activeIndex = index;
|
|
854
|
-
}
|
|
855
|
-
/** Records left `active` by dead processes → `abandoned`. */
|
|
856
|
-
_demoteZombieActives() {
|
|
857
|
-
const activeBase = this._path && path.basename(this._path);
|
|
858
|
-
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
859
|
-
if (rec.state === "active" && name !== activeBase) {
|
|
860
|
-
rec.state = "abandoned";
|
|
861
|
-
rec.abandonedAt = new Date().toISOString();
|
|
862
|
-
this._aevent("abandon", { file: name });
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
/** Heal audit-vs-disk divergence after crashes / external tampering. */
|
|
867
|
-
_reconcileWithDisk() {
|
|
868
|
-
for (const name of Object.keys(this._audit.files)) {
|
|
869
|
-
const rec = this._audit.files[name];
|
|
870
|
-
if (rec.gzipPending)
|
|
871
|
-
continue; // handled by the repair pass
|
|
872
|
-
if (name.endsWith(".gz")) {
|
|
873
|
-
const gzExists = existsSafe(path.join(this.dir, name));
|
|
874
|
-
const rawName = name.slice(0, -3);
|
|
875
|
-
const rawExists = existsSafe(path.join(this.dir, rawName));
|
|
876
|
-
if (gzExists)
|
|
877
|
-
continue;
|
|
878
|
-
if (rawExists) {
|
|
879
|
-
delete this._audit.files[name];
|
|
880
|
-
this._audit.files[rawName] = { ...rec, state: "sealed" };
|
|
881
|
-
this._aevent("archive-regressed", { file: name, backTo: rawName });
|
|
882
|
-
this.emit("warn", new Error(`archive ${name} missing but raw ${rawName} exists`));
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
this._aevent("vanished", { file: name });
|
|
886
|
-
delete this._audit.files[name];
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
const rawExists = existsSafe(path.join(this.dir, name));
|
|
890
|
-
const gzExists = existsSafe(path.join(this.dir, `${name}.gz`));
|
|
891
|
-
if (rawExists)
|
|
892
|
-
continue;
|
|
893
|
-
if (gzExists) {
|
|
894
|
-
this._finishArchivedRecord(name, true); // unlink was interrupted
|
|
895
|
-
continue;
|
|
896
|
-
}
|
|
897
|
-
this._aevent("vanished", { file: name });
|
|
898
|
-
delete this._audit.files[name];
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
/**
|
|
902
|
-
* DISK-DRIVEN seal. Seal raw segments whose period has
|
|
903
|
-
* already passed (crash near midnight, virtual-clock leftovers,
|
|
904
|
-
* untracked/pruned files). Iterates the DIRECTORY, not the manifest:
|
|
905
|
-
* the filesystem is the source of truth; the manifest is an index.
|
|
906
|
-
*/
|
|
907
|
-
async _sealOrphanedPastStamps() {
|
|
908
|
-
if (!this.zippedArchive)
|
|
909
|
-
return;
|
|
910
|
-
const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
|
|
911
|
-
"(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
912
|
-
let names = [];
|
|
913
|
-
try {
|
|
914
|
-
names = fs.readdirSync(this.dir);
|
|
915
|
-
}
|
|
916
|
-
catch {
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
for (const name of names) {
|
|
920
|
-
if (name.endsWith(".gz"))
|
|
921
|
-
continue;
|
|
922
|
-
if (name === (this._path && path.basename(this._path)))
|
|
923
|
-
continue;
|
|
924
|
-
const stamp = stampRx.exec(name)?.[1];
|
|
925
|
-
// lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
|
|
926
|
-
// also guards the current stamp and anything future-dated
|
|
927
|
-
if (!stamp || stamp >= this._curStamp)
|
|
928
|
-
continue;
|
|
929
|
-
const full = path.join(this.dir, name);
|
|
930
|
-
if (!existsSafe(full) || statSizeSafe(full) === 0)
|
|
931
|
-
continue;
|
|
932
|
-
const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
|
|
933
|
-
if (!ok)
|
|
934
|
-
continue;
|
|
935
|
-
if (this._auditEnabled && this._audit.files[name]) {
|
|
936
|
-
// tracked → re-key record name → name.gz (emits archive + journal)
|
|
937
|
-
this._finishArchivedRecord(name, true);
|
|
938
|
-
}
|
|
939
|
-
else {
|
|
940
|
-
// untracked → record it fresh so future reconciles see truth
|
|
941
|
-
const gzName = `${name}.gz`;
|
|
942
|
-
if (this._auditEnabled) {
|
|
943
|
-
this._audit.files[gzName] = {
|
|
944
|
-
state: "archived",
|
|
945
|
-
gzipPending: false,
|
|
946
|
-
archivedAt: new Date().toISOString(),
|
|
947
|
-
};
|
|
948
|
-
this._aevent("seal-past", { file: gzName });
|
|
949
|
-
}
|
|
950
|
-
this.emit("archive", path.join(this.dir, gzName));
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
async _repairInterruptedGzips() {
|
|
955
|
-
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
956
|
-
if (!rec.gzipPending)
|
|
957
|
-
continue;
|
|
958
|
-
const src = path.join(this.dir, name);
|
|
959
|
-
const dst = `${src}.gz`;
|
|
960
|
-
if (!existsSafe(src)) {
|
|
961
|
-
rec.gzipPending = false;
|
|
962
|
-
rec.state = "lost";
|
|
963
|
-
this._aevent("gzip-lost", { file: name });
|
|
964
|
-
continue;
|
|
965
|
-
}
|
|
966
|
-
if (existsSafe(dst)) {
|
|
967
|
-
try {
|
|
968
|
-
fs.unlinkSync(src);
|
|
969
|
-
}
|
|
970
|
-
catch {
|
|
971
|
-
/* ignore */
|
|
972
|
-
}
|
|
973
|
-
this._finishArchivedRecord(name, true);
|
|
974
|
-
continue;
|
|
975
|
-
}
|
|
976
|
-
const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
|
|
977
|
-
if (ok) {
|
|
978
|
-
this._finishArchivedRecord(name, true);
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
rec.gzipPending = false;
|
|
982
|
-
rec.state = "kept-raw";
|
|
983
|
-
this._aevent("gzip-failed", { file: name });
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
/** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
|
|
988
|
-
_finishArchivedRecord(rawName, repaired = false) {
|
|
989
|
-
const rec = this._audit.files[rawName];
|
|
990
|
-
if (!rec)
|
|
991
|
-
return;
|
|
992
|
-
rec.gzipPending = false;
|
|
993
|
-
rec.state = "archived";
|
|
994
|
-
rec.archivedAt = new Date().toISOString();
|
|
995
|
-
if (rawName.endsWith(".gz")) {
|
|
996
|
-
this.emit("archive", path.join(this.dir, rawName));
|
|
997
|
-
this._aevent("archive", { file: rawName, ...(repaired && { repaired }) });
|
|
998
|
-
return;
|
|
999
|
-
}
|
|
1000
|
-
const gzName = `${rawName}.gz`;
|
|
1001
|
-
delete this._audit.files[rawName];
|
|
1002
|
-
this._audit.files[gzName] = rec;
|
|
1003
|
-
this.emit("archive", path.join(this.dir, gzName));
|
|
1004
|
-
this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
|
|
1005
|
-
}
|
|
1006
|
-
/* ==================================================================
|
|
1007
|
-
* Retention
|
|
1008
|
-
* ================================================================ */
|
|
1009
|
-
async _retire() {
|
|
1010
|
-
const policy = this.maxFiles;
|
|
1011
|
-
if (!policy)
|
|
1012
|
-
return;
|
|
1013
|
-
const activeBase = this._path && path.basename(this._path);
|
|
1014
|
-
const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
1015
|
-
const reserved = [
|
|
1016
|
-
path.basename(this._auditPath),
|
|
1017
|
-
`${path.basename(this._auditPath)}.tmp`,
|
|
1018
|
-
`${path.basename(this._auditPath)}.corrupt`,
|
|
1019
|
-
];
|
|
1020
|
-
try {
|
|
1021
|
-
const dirEntries = await fs.promises.readdir(this.dir);
|
|
1022
|
-
const candidates = dirEntries.filter((n) => this._rxActive.test(n) &&
|
|
1023
|
-
n !== activeBase &&
|
|
1024
|
-
!(activeStem && n.startsWith(activeStem)) &&
|
|
1025
|
-
!reserved.includes(n));
|
|
1026
|
-
const stats = [];
|
|
1027
|
-
for (const name of candidates) {
|
|
1028
|
-
const full = path.join(this.dir, name);
|
|
1029
|
-
try {
|
|
1030
|
-
stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
|
|
1031
|
-
}
|
|
1032
|
-
catch {
|
|
1033
|
-
/* raced deletion */
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
1037
|
-
const kill = new Set();
|
|
1038
|
-
if (policy.days != null) {
|
|
1039
|
-
const cutoff = this._now() - policy.days * 86_400_000;
|
|
1040
|
-
for (const f of stats)
|
|
1041
|
-
if (f.mtimeMs < cutoff)
|
|
1042
|
-
kill.add(f.full);
|
|
1043
|
-
}
|
|
1044
|
-
if (policy.count != null) {
|
|
1045
|
-
const survivors = stats.filter((f) => !kill.has(f.full));
|
|
1046
|
-
const excessCount = survivors.length - policy.count;
|
|
1047
|
-
for (let i = 0; i < excessCount; i++)
|
|
1048
|
-
kill.add(survivors[i].full);
|
|
1049
|
-
}
|
|
1050
|
-
if (kill.size === 0)
|
|
1051
|
-
return;
|
|
1052
|
-
for (const file of kill) {
|
|
1053
|
-
await fs.promises.unlink(file).catch(() => { });
|
|
1054
|
-
if (this._auditEnabled) {
|
|
1055
|
-
delete this._audit.files[path.basename(file)];
|
|
1056
|
-
this._aevent("deleted", { file: path.basename(file) });
|
|
1057
|
-
}
|
|
1058
|
-
this.emit("deleted", file);
|
|
1059
|
-
}
|
|
1060
|
-
this._saveAudit();
|
|
1061
|
-
}
|
|
1062
|
-
catch (e) {
|
|
1063
|
-
this.emit("warn", e);
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
/**
|
|
1068
|
-
* Convenience factory mirroring common rotator APIs.
|
|
1069
|
-
*
|
|
1070
|
-
* @example
|
|
1071
|
-
* const log = createStream({ filename: "logs/app-%DATE%.log" });
|
|
1072
|
-
*/
|
|
1073
|
-
function createStream(options) {
|
|
1074
|
-
return new RotateFileStream(options);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
export { RotateFileStream, createStream };
|
|
1
|
+
/*! logroller v2.1.1 | MIT License */
|
|
2
|
+
import{randomUUID as t}from"node:crypto";import e from"node:fs";import i from"node:path";import{Writable as s}from"node:stream";import n from"node:zlib";import{pipeline as a}from"node:stream/promises";const r=t=>{try{return"string"==typeof t?e.statSync(t).size:0}catch{return 0}},h=t=>{try{return e.existsSync(t)}catch{return!1}};async function o(t,i,s,r=6){try{return await a(e.createReadStream(t),n.createGzip({level:r}),e.createWriteStream(i)),await e.promises.unlink(t),!0}catch(t){return await e.promises.unlink(i).catch(()=>{}),s?.(t),!1}}const d=t=>t?new Promise(e=>t.end(()=>e())):Promise.resolve(),c={b:1,k:1024,kb:1024,m:1048576,mb:1048576,g:1073741824,gb:1073741824},u={ms:1,s:1e3,m:6e4,h:36e5,hour:36e5,hours:36e5};function l(t,e,i){if("number"==typeof t)return Math.round(t*u[e]);const s=/^\s*(-?\d+(?:\.\d+)?)\s*(h(?:our)?s?|ms|s|m)?\s*$/i.exec(t);if(!s)throw new Error(`Invalid ${i} "${t}"`);const n=(s[2]??e).toLowerCase(),a=u[n];if(void 0===a)throw new Error(`Invalid ${i} unit "${s[2]}" in "${t}"`);return Math.round(Number.parseFloat(s[1])*a)}function _(t){if(null==t||0===t)return 0;if("number"==typeof t)return t;const e=/^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(t);if(!e)throw new Error(`Invalid maxSize "${t}"`);return Math.round(Number.parseFloat(e[1])*(c[e[2]?.toLowerCase()]??1))}const m={ms:1,s:1e3,m:6e4,h:36e5};const p=new Map;function f(t){let e=p.get(t);if(!e){try{e=new Intl.DateTimeFormat("en-US",{timeZone:t,hourCycle:"h23",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{throw new Error(`Invalid timeZone "${t}"`)}p.set(t,e)}return e}function w(t,e){const i={};for(const s of f(t).formatToParts(e))i[s.type]=s.value;return i}const y=t=>t.padStart(2,"0");function v(t){return t.replace(/[.*+?^${}()|[\]\\]/g,String.raw`\$&`)}function b(t,e){const i=w(t,e);return Date.UTC(+i.year,+i.month-1,+i.day,+i.hour,+i.minute,+i.second)-(e.getTime()-e.getTime()%1e3)}class g extends s{tz;template;dir;datePattern;zippedArchive;maxSize;maxFiles;freq;unrefTimers;maxTotalSize;_tL;_tMid;_tExt;_rxFor;_rxActive;_auditEnabled;_auditPath;_bootId=t();_onSeal;_sealHookTimeoutMs;_compressionLevel;_symlinkPath=null;_offsetMs;_stepMs;_bumpEvery;_curStamp;_index;_ws=null;_path=null;_bytes=0;_timer=null;_bumpTimer=null;_ended=!1;_tail=Promise.resolve();_warnedBackward=!1;_audit;constructor(t={}){super(t.highWaterMark?{highWaterMark:t.highWaterMark}:{});const s=t.filename;if("string"!=typeof s||!s.includes("%DATE%"))throw new Error("opts.filename must contain '%DATE%' (e.g. 'logs/app-%DATE%.log')");var n;this.tz=t.tz||"UTC",f(this.tz),this.template=i.basename(s),this.dir=i.dirname(s),this.datePattern=t.datePattern||"YYYY-MM-DD",this.zippedArchive=!!t.zippedArchive,this.maxSize=_(t.maxSize??0),this.maxFiles=function(t){if(null==t||""===t||0===t)return null;if("number"==typeof t){if(t<0)throw new Error(`Invalid maxFiles "${t}"`);return{count:t}}const e=/^\s*(\d+)\s*d\s*$/i.exec(t);if(e)return{days:Number.parseInt(e[1],10)};const i=/^\s*(\d+)\s*$/.exec(t);if(i)return{count:Number.parseInt(i[1],10)};throw new Error(`Invalid maxFiles "${t}"`)}(t.maxFiles),this.maxTotalSize=_(t.maxTotalSize??0),this.freq=function(t){if(null==t)return{type:"daily"};if("daily"===t)return{type:"daily"};if("number"==typeof t){if(t<=0)throw new Error(`Invalid frequency "${t}"`);return{type:"interval",ms:t}}const e=/^\s*(\d+)\s*(ms|s|m|h)\s*$/i.exec(t);if(!e)throw new Error(`Invalid frequency "${t}"`);return{type:"interval",ms:Number.parseInt(e[1],10)*m[e[2].toLowerCase()]}}(t.frequency),this.unrefTimers=!!t.unrefTimers,this._offsetMs=null==(n=t.addHours)||""===n?0:l(n,"h","addHours"),this._stepMs=function(t){return null==t||""===t?0:l(t,"h","addHoursEveryMin")}(t.addHoursEveryMin),this._bumpEvery=Math.max(50,t.clockStepIntervalMs??6e4),this._compressionLevel=Math.min(9,Math.max(0,t.compressionLevel??6)),this._onSeal=t.onSeal,this._sealHookTimeoutMs=Math.max(0,t.sealHookTimeoutMs??3e4);const a=this.template.indexOf("%DATE%"),r=this.template.slice(a+6),h=r.lastIndexOf(".");this._tL=this.template.slice(0,a),this._tMid=h>=0?r.slice(0,h):"",this._tExt=h>=0?r.slice(h):"",this._rxFor=t=>new RegExp("^"+v(this._tL)+t+v(this._tMid)+"(?:\\.(\\d+))?"+v(this._tExt)+"(?:\\.gz)?$"),this._rxActive=this._rxFor("[^.]+"),this._auditEnabled=!1!==t.audit;const o=(this._tL.replace(/[^\w.-]+/g,"")||"log").replace(/^[-.]+|[-.]+$/g,"");var d,c,u,p;this._auditPath=i.join(this.dir,t.auditFile||`${o}_audit.json`),this._symlinkPath=t.symlink?i.join(this.dir,"string"==typeof t.symlink?t.symlink:`${o}-current.log`):null,e.mkdirSync(this.dir,{recursive:!0}),this._curStamp=this._stampNow(),this._audit=(d=this._tL,c=this._tExt,u=this.datePattern,p=this.tz,{version:1,prefix:d,ext:c,datePattern:u,tz:p,createdAt:null,updatedAt:null,activeStamp:null,activeIndex:null,writer:null,files:{},events:[]});const w=this._auditEnabled&&this._loadAudit();this._index=this._recoverIndexFor(this._curStamp);const y=this._audit.activeIndex;w&&null!==y&&this._audit.activeStamp===this._curStamp&&(this._index=Math.max(this._index,y)),0===this._offsetMs&&0===this._stepMs||process.nextTick(()=>this.emit("clock",{addHours:t.addHours??void 0,addHoursEveryMin:t.addHoursEveryMin??void 0,offsetMs:this._offsetMs})),process.nextTick(()=>{const e=t=>this.emit("warn",new Error(t));"interval"===this.freq.type?(this.freq.ms<864e5&&!this.datePattern.includes("HH")&&e(`frequency "${t.frequency}" needs HH in datePattern — segments would collapse onto one filename`),this.freq.ms<6e4&&!this.datePattern.includes("mm")&&e(`frequency "${t.frequency}" needs mm in datePattern`)):this.datePattern.includes("DD")||e("daily frequency needs DD in datePattern")}),this._job(async()=>{await this._sealOrphanedPastStamps(),this._auditEnabled&&(this._demoteZombieActives(),this._reconcileWithDisk(),await this._repairInterruptedGzips(),this._aevent("boot",{stamp:this._curStamp,index:this._index}),this._saveAudit()),(this.maxFiles||this.maxTotalSize>0)&&this._retire()}).catch(()=>{}),this._armTimer(),this._armBump(),this.once("close",()=>{this._stopTimer(),this._stopBump()})}on(t,e){return super.on(t,e)}once(t,e){return super.once(t,e)}emit(t,...e){return super.emit(t,...e)}get filename(){return this._path}get currentIndex(){return this._index}rotateNow(){return this._job(()=>this._rotate("manual"))}advanceClock(){return this._job(()=>this._applyBump())}listSegments(){const t=new RegExp("^"+v(this._tL)+"(.+?)"+v(this._tMid)+"(?:\\.(\\d+))?"+v(this._tExt)+"(?:\\.gz)?$"),s=this._symlinkPath&&i.basename(this._symlinkPath),n=[];let a=[];try{a=e.readdirSync(this.dir)}catch{return n}for(const e of a){if(e===s)continue;const a=t.exec(e);if(!a)continue;const h=this._audit.files[e];n.push({file:i.join(this.dir,e),stamp:a[1],index:a[2]?Number.parseInt(a[2],10):0,gzipped:e.endsWith(".gz"),bytes:r(i.join(this.dir,e)),state:h?.state??"untracked",openedAt:h?.openedAt,archivedAt:h?.archivedAt})}return n.sort((t,e)=>t.stamp.localeCompare(e.stamp)||t.index-e.index),n}stats(){const t=this.listSegments();return{dir:this.dir,currentStamp:this._curStamp,activeFile:this._path,activeIndex:this._index,segments:t.length,archived:t.filter(t=>t.gzipped).length,totalBytes:t.reduce((t,e)=>t+e.bytes,0),clockOffsetMs:this._offsetMs,symlink:this._symlinkPath}}_write(t,e,i){if(this._ended)return void process.nextTick(i,new Error("write after end"));const s=t.length;this._job(async()=>{try{this._stampAdvanced(this._stampNow())&&await this._rotate("time"),await this._ensureOpen(),this.maxSize>0&&this._bytes>0&&this._bytes+s>this.maxSize&&(await this._rotate("size"),await this._ensureOpen());const n=this._ws;if(!n)throw new Error("segment write stream not open");await new Promise((i,s)=>n.write(t,e,t=>t?s(t):i())),this._bytes+=s,i()}catch(t){this.emit("error",t),i(t)}}).catch(()=>{})}_final(t){this._ended=!0,this._stopTimer(),this._stopBump(),this._job(async()=>{await d(this._ws);const t=this._path&&i.basename(this._path);if(t&&this._audit.files[t]){const e=this._audit.files[t];e.state="closed",e.closedAt=(new Date).toISOString(),e.bytes=r(this._path),this._aevent("close",{file:t}),this._saveAudit()}this._ws=null,this._path=null}).catch(()=>{}).finally(()=>t())}destroy(...t){return this._stopTimer(),this._stopBump(),super.destroy(...t)}_job(t){const e=this._tail.then(t,t);return this._tail=e.catch(()=>{}),e}_now(){return Date.now()+this._offsetMs}_stampNow(t=new Date(this._now())){return e=this.datePattern,i=w(this.tz,t),e.replace(/YYYY|YY|MM|DD|HH|mm|ss/g,t=>{switch(t){case"YYYY":return i.year;case"YY":return i.year.slice(-2);case"MM":return y(i.month);case"DD":return y(i.day);case"HH":return y(i.hour);case"mm":return y(i.minute);case"ss":return y(i.second);default:return t}});var e,i}_resolveName(t,e){const s=i.join(this.dir,this._tL+t+this._tMid+this._tExt);return e?/\.[^.]+$/.test(s)?s.replace(/\.[^.]+$/,`.${e}$&`):`${s}.${e}`:s}_stampAdvanced(t){return t>this._curStamp||(t<this._curStamp&&!this._warnedBackward&&(this._warnedBackward=!0,this.emit("warn",new Error(`clock moved backward (stamp ${t} < ${this._curStamp}); keeping current segment until the clock catches up`))),!1)}_recoverIndexFor(t){const i=this._rxFor(v(t));let s=-1,n=-1,a=[];try{a=e.readdirSync(this.dir)}catch{}for(const t of a){const e=i.exec(t);if(!e)continue;const a=e[1]?Number.parseInt(e[1],10):0;a>s&&(s=a),!t.endsWith(".gz")&&a>n&&(n=a)}let r=n>=0?n:s>=0?s+1:0;const h=this._audit.activeIndex;return this._auditEnabled&&null!==h&&this._audit.activeStamp===t&&(r=Math.max(r,h)),r}_updateSymlink(){const t=this._symlinkPath;if(!t||!this._path)return;const s=`${t}.tmp`;try{try{e.unlinkSync(s)}catch{}e.symlinkSync(i.basename(this._path),s,"file"),e.renameSync(s,t)}catch(e){this._symlinkPath=null,this.emit("warn",new Error(`symlink unavailable ("${t}"): ${e.message} — continuing without it`))}}async _runSealHook(t,e){const s=this._onSeal;if(!s)return;let n;try{await Promise.race([Promise.resolve().then(()=>s({raw:t,gz:e})),new Promise((s,a)=>{n=setTimeout(()=>a(new Error(`onSeal hook timed out after ${this._sealHookTimeoutMs}ms (${i.basename(e??t)})`)),this._sealHookTimeoutMs)})])}catch(t){this.emit("warn",t)}finally{n&&clearTimeout(n)}}async _ensureOpen(){if(this._ws)return;const t=this._resolveName(this._curStamp,this._index);this._bytes=r(t);const s=e.createWriteStream(t,{flags:"a"});if(s.on("error",t=>this.emit("error",t)),this._ws=s,this._path=t,this._updateSymlink(),this._auditEnabled){const e=i.basename(t);this._audit.files[e]={index:this._index,state:"active",gzipPending:!1,openedAt:(new Date).toISOString()},this._setActivePointer(this._index),this._aevent("open",{file:e,index:this._index,resumedBytes:this._bytes}),this._saveAudit()}this.emit("open",t)}async _rotate(t){const s=this._path,n=this._ws;this._ws=null,this._path=null,this._bytes=0;let a=null;if(n&&s){await d(n);const t=r(s),c=i.basename(s),u=this._auditEnabled?this._audit.files[c]:void 0;if(this.zippedArchive&&t>0){a=`${s}.gz`,u&&(u.gzipPending=!0,u.bytes=t,this._saveAudit());await o(s,a,t=>this.emit("warn",t),this._compressionLevel)?(this._finishArchivedRecord(c),await this._runSealHook(s,a)):(u&&(u.gzipPending=!1,u.state="kept-raw"),this._aevent("gzip-failed",{file:c}),a=null,await this._runSealHook(s,null))}else if(0===t){const t=`${s}.gz`;if(h(t))this._finishArchivedRecord(c),a=t,await this._runSealHook(s,t);else{try{e.unlinkSync(s)}catch{}u&&delete this._audit.files[c],this._aevent("drop-empty",{file:c})}}else t>0&&(u&&(u.state="sealed",u.bytes=t),await this._runSealHook(s,null))}let c=this._stampNow();c<this._curStamp&&(this._warnedBackward||(this._warnedBackward=!0,this.emit("warn",new Error(`clock moved backward (stamp ${c} < ${this._curStamp}); rotation clamped to the current period`))),c=this._curStamp);const u=c!==this._curStamp;this._curStamp=c,this._index=u||"time"===t?this._recoverIndexFor(c):this._index+1,this._bytes=0,this._auditEnabled&&(this._setActivePointer(this._index),this._aevent("rotate",{reason:t,nextIndex:this._index,stamp:c}),this._saveAudit()),"time"!==t&&"manual"!==t||this._armTimer(),s&&this.emit("rotated",{reason:t,oldFile:s,archive:a,newFile:this._resolveName(c,this._index)}),this._retire()}_hasContent(){return this._ws?this._bytes>0||r(this._path)>0:!!(this._path&&r(this._path)>0)}_armTimer(){if(this._stopTimer(),this._ended)return;const t=this._now();var e,i;const s=("daily"===this.freq.type?function(t,e){const i=w(t,new Date(e)),s=Date.UTC(+i.year,+i.month-1,+i.day+1);let n=s-b(t,new Date(s));for(let e=0;e<3;e++){const e=s-b(t,new Date(n));if(e===n)break;n=e}return n<=e&&(n+=864e5),n}(this.tz,t):(e=t,i=this.freq.ms,Math.floor(e/i)*i+i))-this._now(),n=s<0?1e3:Math.max(100,s);this._timer=setTimeout(()=>{this._job(async()=>{const t=this._stampNow();"daily"!==this.freq.type?this._hasContent()&&await this._rotate("time"):this._stampAdvanced(t)&&(this._hasContent()?await this._rotate("time"):(this._curStamp=t,this._index=this._recoverIndexFor(t),this._auditEnabled&&(this._setActivePointer(this._index),this._aevent("period",{stamp:t}),this._saveAudit()),this.emit("period",t))),this._armTimer()}).catch(()=>{})},n),this.unrefTimers&&this._timer.unref()}_armBump(){this._stopBump(),this._stepMs&&!this._ended&&(this._bumpTimer=setInterval(()=>this._job(()=>this._applyBump()).catch(()=>{}),this._bumpEvery),this.unrefTimers&&this._bumpTimer.unref())}_stopBump(){this._bumpTimer&&(clearInterval(this._bumpTimer),this._bumpTimer=null)}async _applyBump(){if(this._ended)return;this._offsetMs+=this._stepMs,this._armTimer();const t=this._stampNow();this._stampAdvanced(t)&&(this._hasContent()?await this._rotate("time"):(this._curStamp=t,this._index=this._recoverIndexFor(t),this._auditEnabled&&(this._setActivePointer(this._index),this._aevent("period",{stamp:t,virtual:!0})),this.emit("period",t))),this._auditEnabled&&(this._aevent("clock-step",{addedMsPerMin:this._stepMs,offsetMs:this._offsetMs,stamp:t}),this._saveAudit())}_stopTimer(){this._timer&&(clearTimeout(this._timer),this._timer=null)}_loadAudit(){let i;try{i=JSON.parse(e.readFileSync(this._auditPath,"utf8"))}catch{try{e.renameSync(this._auditPath,`${this._auditPath}.corrupt`)}catch{}return!1}const s=i;if(!s||1!==s.version||s.prefix!==this._tL||s.ext!==this._tExt){try{e.renameSync(this._auditPath,`${this._auditPath}.corrupt`)}catch{}return!1}this._audit.createdAt=s.createdAt??(new Date).toISOString(),this._audit.activeStamp="string"==typeof s.activeStamp?s.activeStamp:null,this._audit.activeIndex=Number.isInteger(s.activeIndex)?s.activeIndex:null,this._audit.files=s.files&&"object"==typeof s.files?s.files:{},this._audit.events=Array.isArray(s.events)?s.events.slice(-200):[];const n=s.writer;if(n&&"string"==typeof n.bootId&&n.bootId!==this._bootId&&"number"==typeof n.heartbeat&&Date.now()-n.heartbeat<3e4){const e=new Error(`concurrent writer detected (pid ${n.pid}, boot ${n.bootId}); two processes share this log family and will race. If running 'node --watch', exclude the logs dir via --watch-path.`);this.emit("warn",e),this._audit.events.push({id:t(),t:(new Date).toISOString(),type:"concurrent-writer",otherPid:n.pid,otherBoot:n.bootId})}return!0}_saveAudit(){if(this._auditEnabled)try{this._pruneAuditFiles(),this._audit.updatedAt=(new Date).toISOString(),this._audit.writer={pid:process.pid,bootId:this._bootId,heartbeat:Date.now()};const t=`${this._auditPath}.tmp`;e.writeFileSync(t,JSON.stringify(this._audit,null,1)),e.renameSync(t,this._auditPath)}catch(t){this.emit("warn",t)}}_pruneAuditFiles(){const t=this._audit.files,e=Object.keys(t);if(e.length<=600)return;const s=this._path&&i.basename(this._path),n=t=>t.archivedAt??t.closedAt??t.abandonedAt??t.openedAt??"",a=e.filter(e=>e!==s&&"active"!==t[e].state).sort((e,i)=>n(t[e]).localeCompare(n(t[i]))),r=e.length-600+50;for(const e of a.slice(0,r))delete t[e]}_aevent(e,i={}){if(!this._auditEnabled)return;const s=this._audit.events;s.push({id:t(),t:(new Date).toISOString(),type:e,...i}),s.length>200&&s.splice(0,s.length-200)}_setActivePointer(t){this._audit.activeStamp=this._curStamp,this._audit.activeIndex=t}_demoteZombieActives(){const t=this._path&&i.basename(this._path);for(const[e,i]of Object.entries(this._audit.files))"active"===i.state&&e!==t&&(i.state="abandoned",i.abandonedAt=(new Date).toISOString(),this._aevent("abandon",{file:e}))}_reconcileWithDisk(){for(const t of Object.keys(this._audit.files)){const e=this._audit.files[t];if(e.gzipPending)continue;if(t.endsWith(".gz")){const s=h(i.join(this.dir,t)),n=t.slice(0,-3),a=h(i.join(this.dir,n));if(s)continue;if(a){delete this._audit.files[t],this._audit.files[n]={...e,state:"sealed"},this._aevent("archive-regressed",{file:t,backTo:n}),this.emit("warn",new Error(`archive ${t} missing but raw ${n} exists`));continue}this._aevent("vanished",{file:t}),delete this._audit.files[t];continue}const s=h(i.join(this.dir,t)),n=h(i.join(this.dir,`${t}.gz`));s||(n?this._finishArchivedRecord(t,!0):(this._aevent("vanished",{file:t}),delete this._audit.files[t]))}}async _sealOrphanedPastStamps(){if(!this.zippedArchive)return;const t=new RegExp("^"+v(this._tL)+"(.+?)"+v(this._tMid)+"(?:\\.\\d+)?"+v(this._tExt)+"(?:\\.gz)?$");let s=[];try{s=e.readdirSync(this.dir)}catch{return}for(const e of s){if(e.endsWith(".gz"))continue;if(e===(this._path&&i.basename(this._path)))continue;const s=t.exec(e)?.[1];if(!s||s>=this._curStamp)continue;const n=i.join(this.dir,e);if(!h(n)||0===r(n))continue;const a=`${n}.gz`;await o(n,a,t=>this.emit("warn",t),this._compressionLevel)&&(this._auditEnabled&&this._audit.files[e]?this._finishArchivedRecord(e,!0):(this._auditEnabled&&(this._audit.files[a]={state:"archived",gzipPending:!1,archivedAt:(new Date).toISOString()},this._aevent("seal-past",{file:a})),this.emit("archive",a)),await this._runSealHook(n,a))}}async _repairInterruptedGzips(){for(const[t,s]of Object.entries(this._audit.files)){if(!s.gzipPending)continue;const n=i.join(this.dir,t),a=`${n}.gz`;if(!h(n)){s.gzipPending=!1,s.state="lost",this._aevent("gzip-lost",{file:t});continue}if(h(a)){try{e.unlinkSync(n)}catch{}this._finishArchivedRecord(t,!0),await this._runSealHook(n,a);continue}await o(n,a,t=>this.emit("warn",t),this._compressionLevel)?(this._finishArchivedRecord(t,!0),await this._runSealHook(n,a)):(s.gzipPending=!1,s.state="kept-raw",this._aevent("gzip-failed",{file:t}))}}_finishArchivedRecord(t,e=!1){const s=this._audit.files[t];if(!s)return;if(s.gzipPending=!1,s.state="archived",s.archivedAt=(new Date).toISOString(),t.endsWith(".gz"))return this.emit("archive",i.join(this.dir,t)),void this._aevent("archive",{file:t,...e&&{repaired:e}});const n=`${t}.gz`;delete this._audit.files[t],this._audit.files[n]=s,this.emit("archive",i.join(this.dir,n)),this._aevent("archive",{file:n,...e&&{repaired:e}})}async _retire(){const t=this.maxFiles;if(!t&&this.maxTotalSize<=0)return;const s=this._path&&i.basename(this._path),n=s?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/,""),a=[i.basename(this._auditPath),`${i.basename(this._auditPath)}.tmp`,`${i.basename(this._auditPath)}.corrupt`,...this._symlinkPath?[i.basename(this._symlinkPath),`${i.basename(this._symlinkPath)}.tmp`]:[]];try{const r=(await e.promises.readdir(this.dir)).filter(t=>this._rxActive.test(t)&&t!==s&&!(n&&t.startsWith(n))&&!a.includes(t)),h=[];for(const t of r){const s=i.join(this.dir,t);try{const t=await e.promises.stat(s);h.push({full:s,mtimeMs:t.mtimeMs,size:t.size})}catch{}}h.sort((t,e)=>t.mtimeMs-e.mtimeMs);const o=new Set;if(null!=t?.days){const e=this._now()-864e5*t.days;for(const t of h)t.mtimeMs<e&&o.add(t.full)}if(null!=t?.count){const e=h.filter(t=>!o.has(t.full)),i=e.length-t.count;for(let t=0;t<i;t++)o.add(e[t].full)}if(this.maxTotalSize>0){const t=h.filter(t=>!o.has(t.full));let e=t.reduce((t,e)=>t+e.size,0),i=0;for(;e>this.maxTotalSize&&i<t.length;)o.add(t[i].full),e-=t[i].size,i++}if(0===o.size)return;for(const t of o)await e.promises.unlink(t).catch(()=>{}),this._auditEnabled&&(delete this._audit.files[i.basename(t)],this._aevent("deleted",{file:i.basename(t)})),this.emit("deleted",t);this._saveAudit()}catch(t){this.emit("warn",t)}}}function x(t){return new g(t)}const S=new Set;let k=!1,z=!1;function A(t,e={}){const i=Array.isArray(t)?t:[t];for(const t of i)S.add(t);if(!k){k=!0;const t=e.timeoutMs??5e3,i=e.code??0;for(const s of e.signals??["SIGINT","SIGTERM"])process.on(s,()=>{M(t).finally(()=>process.exit(i))})}return async()=>{await M(e.timeoutMs??5e3);for(const t of i)S.delete(t)}}async function M(t=5e3){if(z)return;z=!0;const e=setTimeout(()=>process.exit(1),t);e.unref();try{await Promise.allSettled([...S].map(t=>new Promise(e=>t.end(()=>e()))))}finally{clearTimeout(e),z=!1}}export{g as RotateFileStream,x as createStream,M as flushAll,A as installShutdown};
|