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