logroller 1.0.0 → 2.0.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/CHANGELOG.md +101 -0
- package/README.md +144 -95
- package/dist/fsio.d.ts +11 -0
- package/dist/index.cjs +564 -448
- package/dist/index.d.ts +7 -140
- package/dist/index.esm.js +564 -448
- package/dist/parsers.d.ts +16 -0
- package/dist/rotate-file-stream.d.ts +121 -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
|
-
/*! logroller
|
|
1
|
+
/*! logroller v2.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,42 +264,46 @@ 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();
|
|
264
305
|
this._reconcileWithDisk();
|
|
306
|
+
await this._sealOrphanedPastStamps();
|
|
265
307
|
await this._repairInterruptedGzips();
|
|
266
308
|
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
267
309
|
this._saveAudit();
|
|
@@ -276,250 +318,53 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
276
318
|
this._stopBump();
|
|
277
319
|
});
|
|
278
320
|
}
|
|
279
|
-
|
|
321
|
+
on(event, listener) {
|
|
322
|
+
return super.on(event, listener);
|
|
323
|
+
}
|
|
324
|
+
once(event, listener) {
|
|
325
|
+
return super.once(event, listener);
|
|
326
|
+
}
|
|
327
|
+
emit(event, ...args) {
|
|
328
|
+
return super.emit(event, ...args);
|
|
329
|
+
}
|
|
330
|
+
/* ==================================================================
|
|
331
|
+
* Public API
|
|
332
|
+
* ================================================================ */
|
|
333
|
+
/** Absolute path of the segment currently written; `null` until first write. */
|
|
280
334
|
get filename() {
|
|
281
335
|
return this._path;
|
|
282
336
|
}
|
|
337
|
+
/** Index of the active segment within the current period. */
|
|
283
338
|
get currentIndex() {
|
|
284
339
|
return this._index;
|
|
285
340
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Force an immediate rotation and resolve when the next slot is staged.
|
|
343
|
+
*/
|
|
289
344
|
rotateNow() {
|
|
290
345
|
return this._job(() => this._rotate("manual"));
|
|
291
346
|
}
|
|
292
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Apply ONE virtual-clock tick immediately (bypasses the interval timer
|
|
349
|
+
* for deterministic tests). Crossing into a new period triggers the same
|
|
350
|
+
* archive flow as a real midnight roll.
|
|
351
|
+
*/
|
|
293
352
|
advanceClock() {
|
|
294
353
|
return this._job(() => this._applyBump());
|
|
295
354
|
}
|
|
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) {
|
|
355
|
+
/* ==================================================================
|
|
356
|
+
* Writable contract
|
|
357
|
+
* ================================================================ */
|
|
358
|
+
_write(chunk, encoding, callback) {
|
|
514
359
|
if (this._ended) {
|
|
515
|
-
process.nextTick(
|
|
360
|
+
process.nextTick(callback, new Error("write after end"));
|
|
516
361
|
return;
|
|
517
362
|
}
|
|
518
363
|
const len = chunk.length;
|
|
519
364
|
this._job(async () => {
|
|
520
365
|
try {
|
|
521
|
-
const
|
|
522
|
-
if (
|
|
366
|
+
const stamp = this._stampNow();
|
|
367
|
+
if (stamp !== this._curStamp)
|
|
523
368
|
await this._rotate("time");
|
|
524
369
|
await this._ensureOpen();
|
|
525
370
|
if (this.maxSize > 0 &&
|
|
@@ -528,50 +373,56 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
528
373
|
await this._rotate("size");
|
|
529
374
|
await this._ensureOpen();
|
|
530
375
|
}
|
|
531
|
-
// ◀ TS FIX: narrow instead of `this._ws!`
|
|
532
376
|
const ws = this._ws;
|
|
533
377
|
if (!ws)
|
|
534
|
-
throw new Error("write stream not open");
|
|
535
|
-
await new Promise((
|
|
378
|
+
throw new Error("segment write stream not open");
|
|
379
|
+
await new Promise((resolve, reject) => ws.write(chunk, encoding, (e) => (e ? reject(e) : resolve())));
|
|
536
380
|
this._bytes += len;
|
|
537
|
-
|
|
381
|
+
callback();
|
|
538
382
|
}
|
|
539
383
|
catch (err) {
|
|
540
|
-
this.emit("error", err);
|
|
541
|
-
|
|
384
|
+
this.emit("error", err);
|
|
385
|
+
callback(err);
|
|
542
386
|
}
|
|
543
387
|
}).catch(() => { });
|
|
544
388
|
}
|
|
545
|
-
_final(
|
|
389
|
+
_final(callback) {
|
|
546
390
|
this._ended = true;
|
|
547
391
|
this._stopTimer();
|
|
548
392
|
this._stopBump();
|
|
549
393
|
this._job(async () => {
|
|
550
394
|
await endStream(this._ws);
|
|
551
|
-
const
|
|
552
|
-
if (
|
|
553
|
-
this._audit.files[
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
395
|
+
const base = this._path && path.basename(this._path);
|
|
396
|
+
if (base && this._audit.files[base]) {
|
|
397
|
+
const rec = this._audit.files[base];
|
|
398
|
+
rec.state = "closed";
|
|
399
|
+
rec.closedAt = new Date().toISOString();
|
|
400
|
+
rec.bytes = statSizeSafe(this._path);
|
|
401
|
+
this._aevent("close", { file: base });
|
|
557
402
|
this._saveAudit();
|
|
558
403
|
}
|
|
559
404
|
this._ws = null;
|
|
560
405
|
this._path = null;
|
|
561
406
|
})
|
|
562
407
|
.catch(() => { })
|
|
563
|
-
.finally(() =>
|
|
408
|
+
.finally(() => callback());
|
|
564
409
|
}
|
|
565
410
|
destroy(...args) {
|
|
566
411
|
this._stopTimer();
|
|
567
412
|
this._stopBump();
|
|
568
413
|
return super.destroy(...args);
|
|
569
414
|
}
|
|
570
|
-
/*
|
|
415
|
+
/* ==================================================================
|
|
416
|
+
* Serialisation — every mutating operation funnels through one queue
|
|
417
|
+
* so rotations/gzips/writes never interleave.
|
|
418
|
+
* ================================================================ */
|
|
571
419
|
_job(fn) {
|
|
572
|
-
const
|
|
573
|
-
this._tail =
|
|
574
|
-
return
|
|
420
|
+
const run = this._tail.then(fn, fn);
|
|
421
|
+
this._tail = run.catch(() => { });
|
|
422
|
+
return run;
|
|
423
|
+
}
|
|
424
|
+
_now() {
|
|
425
|
+
return Date.now() + this._offsetMs;
|
|
575
426
|
}
|
|
576
427
|
_stampNow(d = new Date(this._now())) {
|
|
577
428
|
return formatStamp(this.datePattern, partsIn(this.tz, d));
|
|
@@ -584,6 +435,11 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
584
435
|
? base.replace(/\.[^.]+$/, `.${index}$&`)
|
|
585
436
|
: `${base}.${index}`;
|
|
586
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Highest viable segment index for `stamp`.
|
|
440
|
+
* Disk is truth: resume a raw tail; start after sealed ones.
|
|
441
|
+
* The audit pointer (same stamp only) may only RAISE the result.
|
|
442
|
+
*/
|
|
587
443
|
_recoverIndexFor(stamp) {
|
|
588
444
|
const rx = this._rxFor(escRe(stamp));
|
|
589
445
|
let maxAny = -1;
|
|
@@ -593,7 +449,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
593
449
|
names = fs.readdirSync(this.dir);
|
|
594
450
|
}
|
|
595
451
|
catch {
|
|
596
|
-
/*
|
|
452
|
+
/* unreadable dir → fresh series */
|
|
597
453
|
}
|
|
598
454
|
for (const n of names) {
|
|
599
455
|
const m = rx.exec(n);
|
|
@@ -606,11 +462,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
606
462
|
maxRaw = idx;
|
|
607
463
|
}
|
|
608
464
|
let idx = maxRaw >= 0 ? maxRaw : maxAny >= 0 ? maxAny + 1 : 0;
|
|
609
|
-
// ◀ TS FIX: local const narrows away null
|
|
610
465
|
const ai = this._audit.activeIndex;
|
|
611
|
-
if (this._auditEnabled &&
|
|
612
|
-
ai !== null &&
|
|
613
|
-
this._audit.activeStamp === stamp) {
|
|
466
|
+
if (this._auditEnabled && ai !== null && this._audit.activeStamp === stamp) {
|
|
614
467
|
idx = Math.max(idx, ai);
|
|
615
468
|
}
|
|
616
469
|
return idx;
|
|
@@ -625,10 +478,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
625
478
|
this._ws = ws;
|
|
626
479
|
this._path = p;
|
|
627
480
|
if (this._auditEnabled) {
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
this._audit.files[b] = {
|
|
631
|
-
...(prev && prev.state === "archived" ? {} : prev),
|
|
481
|
+
const base = path.basename(p);
|
|
482
|
+
this._audit.files[base] = {
|
|
632
483
|
index: this._index,
|
|
633
484
|
state: "active",
|
|
634
485
|
gzipPending: false,
|
|
@@ -636,7 +487,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
636
487
|
};
|
|
637
488
|
this._setActivePointer(this._index);
|
|
638
489
|
this._aevent("open", {
|
|
639
|
-
file:
|
|
490
|
+
file: base,
|
|
640
491
|
index: this._index,
|
|
641
492
|
resumedBytes: this._bytes,
|
|
642
493
|
});
|
|
@@ -651,75 +502,81 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
651
502
|
this._path = null;
|
|
652
503
|
this._bytes = 0;
|
|
653
504
|
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
505
|
if (oldWs && oldPath) {
|
|
657
506
|
await endStream(oldWs);
|
|
658
|
-
const
|
|
659
|
-
const
|
|
660
|
-
const
|
|
661
|
-
if (this.zippedArchive &&
|
|
662
|
-
archive = oldPath
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
507
|
+
const realBytes = statSizeSafe(oldPath);
|
|
508
|
+
const oldBase = path.basename(oldPath);
|
|
509
|
+
const rec = this._auditEnabled ? this._audit.files[oldBase] : undefined;
|
|
510
|
+
if (this.zippedArchive && realBytes > 0) {
|
|
511
|
+
archive = `${oldPath}.gz`;
|
|
512
|
+
/* Mark intent BEFORE compressing: crash mid-gzip becomes a
|
|
513
|
+
repairable `gzipPending` flag instead of silent divergence. */
|
|
514
|
+
if (rec) {
|
|
515
|
+
rec.gzipPending = true;
|
|
516
|
+
rec.bytes = realBytes;
|
|
666
517
|
this._saveAudit();
|
|
667
518
|
}
|
|
668
519
|
const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
|
|
669
520
|
if (ok) {
|
|
670
|
-
this._finishArchivedRecord(
|
|
521
|
+
this._finishArchivedRecord(oldBase);
|
|
671
522
|
}
|
|
672
523
|
else {
|
|
673
|
-
if (
|
|
674
|
-
|
|
675
|
-
|
|
524
|
+
if (rec) {
|
|
525
|
+
rec.gzipPending = false;
|
|
526
|
+
rec.state = "kept-raw";
|
|
676
527
|
}
|
|
677
|
-
this._aevent("gzip-failed", { file:
|
|
528
|
+
this._aevent("gzip-failed", { file: oldBase });
|
|
678
529
|
archive = null;
|
|
679
530
|
}
|
|
680
531
|
}
|
|
681
|
-
else if (
|
|
682
|
-
|
|
683
|
-
|
|
532
|
+
else if (realBytes === 0) {
|
|
533
|
+
const adoptedGz = `${oldPath}.gz`;
|
|
534
|
+
if (existsSafe(adoptedGz)) {
|
|
535
|
+
// A concurrent writer archived this segment between our last stat
|
|
536
|
+
// and now — adopt its artifact instead of dropping the record.
|
|
537
|
+
this._finishArchivedRecord(oldBase);
|
|
538
|
+
archive = adoptedGz;
|
|
684
539
|
}
|
|
685
|
-
|
|
686
|
-
|
|
540
|
+
else {
|
|
541
|
+
try {
|
|
542
|
+
fs.unlinkSync(oldPath);
|
|
543
|
+
}
|
|
544
|
+
catch { /* already gone */ }
|
|
545
|
+
if (rec)
|
|
546
|
+
delete this._audit.files[oldBase];
|
|
547
|
+
this._aevent("drop-empty", { file: oldBase });
|
|
687
548
|
}
|
|
688
|
-
if (arec)
|
|
689
|
-
delete this._audit.files[abase];
|
|
690
|
-
this._aevent("drop-empty", { file: abase });
|
|
691
549
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
550
|
+
const stamp = this._stampNow();
|
|
551
|
+
const newPeriod = stamp !== this._curStamp;
|
|
552
|
+
this._curStamp = stamp;
|
|
553
|
+
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
554
|
+
this._index =
|
|
555
|
+
newPeriod || kind === "time"
|
|
556
|
+
? this._recoverIndexFor(stamp)
|
|
557
|
+
: this._index + 1;
|
|
558
|
+
this._bytes = 0;
|
|
559
|
+
if (this._auditEnabled) {
|
|
560
|
+
this._setActivePointer(this._index);
|
|
561
|
+
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
|
|
562
|
+
this._saveAudit();
|
|
695
563
|
}
|
|
564
|
+
if (kind === "time" || kind === "manual")
|
|
565
|
+
this._armTimer();
|
|
566
|
+
if (oldPath) {
|
|
567
|
+
this.emit("rotated", {
|
|
568
|
+
reason: kind,
|
|
569
|
+
oldFile: oldPath,
|
|
570
|
+
archive,
|
|
571
|
+
newFile: this._resolveName(stamp, this._index),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
this._retire();
|
|
696
575
|
}
|
|
697
|
-
const s = this._stampNow();
|
|
698
|
-
const newPeriod = s !== this._curStamp;
|
|
699
|
-
this._curStamp = s;
|
|
700
|
-
this._index = newPeriod
|
|
701
|
-
? this._recoverIndexFor(s)
|
|
702
|
-
: kind === "time"
|
|
703
|
-
? this._recoverIndexFor(s)
|
|
704
|
-
: this._index + 1;
|
|
705
|
-
this._bytes = 0;
|
|
706
|
-
if (this._auditEnabled) {
|
|
707
|
-
this._setActivePointer(this._index);
|
|
708
|
-
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp: s });
|
|
709
|
-
this._saveAudit();
|
|
710
|
-
}
|
|
711
|
-
if (kind === "time" || kind === "manual")
|
|
712
|
-
this._armTimer();
|
|
713
|
-
if (oldPath)
|
|
714
|
-
this.emit("rotated", {
|
|
715
|
-
reason: kind,
|
|
716
|
-
oldFile: oldPath,
|
|
717
|
-
archive,
|
|
718
|
-
newFile: this._resolveName(s, this._index),
|
|
719
|
-
});
|
|
720
|
-
this._retire();
|
|
721
576
|
}
|
|
722
|
-
/*
|
|
577
|
+
/* ==================================================================
|
|
578
|
+
* Timers
|
|
579
|
+
* ================================================================ */
|
|
723
580
|
_hasContent() {
|
|
724
581
|
return this._ws
|
|
725
582
|
? this._bytes > 0 || statSizeSafe(this._path) > 0
|
|
@@ -735,21 +592,22 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
735
592
|
: nextAligned(now, this.freq.ms);
|
|
736
593
|
this._timer = setTimeout(() => {
|
|
737
594
|
this._job(async () => {
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
595
|
+
const stamp = this._stampNow();
|
|
596
|
+
const dueDaily = this.freq.type === "daily" && stamp !== this._curStamp;
|
|
597
|
+
const dueInterval = this.freq.type !== "daily";
|
|
598
|
+
if (dueDaily || dueInterval) {
|
|
741
599
|
if (this._hasContent()) {
|
|
742
600
|
await this._rotate("time");
|
|
743
601
|
}
|
|
744
|
-
else if (
|
|
745
|
-
this._curStamp =
|
|
746
|
-
this._index = this._recoverIndexFor(
|
|
602
|
+
else if (stamp !== this._curStamp) {
|
|
603
|
+
this._curStamp = stamp;
|
|
604
|
+
this._index = this._recoverIndexFor(stamp);
|
|
747
605
|
if (this._auditEnabled) {
|
|
748
606
|
this._setActivePointer(this._index);
|
|
749
|
-
this._aevent("period", { stamp
|
|
607
|
+
this._aevent("period", { stamp });
|
|
750
608
|
this._saveAudit();
|
|
751
609
|
}
|
|
752
|
-
this.emit("period",
|
|
610
|
+
this.emit("period", stamp);
|
|
753
611
|
}
|
|
754
612
|
}
|
|
755
613
|
this._armTimer();
|
|
@@ -777,26 +635,26 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
777
635
|
return;
|
|
778
636
|
this._offsetMs += this._stepMs;
|
|
779
637
|
this._armTimer();
|
|
780
|
-
const
|
|
781
|
-
if (
|
|
638
|
+
const stamp = this._stampNow();
|
|
639
|
+
if (stamp !== this._curStamp) {
|
|
782
640
|
if (this._hasContent()) {
|
|
783
641
|
await this._rotate("time");
|
|
784
642
|
}
|
|
785
643
|
else {
|
|
786
|
-
this._curStamp =
|
|
787
|
-
this._index = this._recoverIndexFor(
|
|
644
|
+
this._curStamp = stamp;
|
|
645
|
+
this._index = this._recoverIndexFor(stamp);
|
|
788
646
|
if (this._auditEnabled) {
|
|
789
647
|
this._setActivePointer(this._index);
|
|
790
|
-
this._aevent("period", { stamp
|
|
648
|
+
this._aevent("period", { stamp, virtual: true });
|
|
791
649
|
}
|
|
792
|
-
this.emit("period",
|
|
650
|
+
this.emit("period", stamp);
|
|
793
651
|
}
|
|
794
652
|
}
|
|
795
653
|
if (this._auditEnabled) {
|
|
796
654
|
this._aevent("clock-step", {
|
|
797
655
|
addedMsPerMin: this._stepMs,
|
|
798
656
|
offsetMs: this._offsetMs,
|
|
799
|
-
stamp
|
|
657
|
+
stamp,
|
|
800
658
|
});
|
|
801
659
|
this._saveAudit();
|
|
802
660
|
}
|
|
@@ -807,66 +665,324 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
807
665
|
this._timer = null;
|
|
808
666
|
}
|
|
809
667
|
}
|
|
810
|
-
/*
|
|
668
|
+
/* ==================================================================
|
|
669
|
+
* Audit manifest
|
|
670
|
+
* ================================================================ */
|
|
671
|
+
_loadAudit() {
|
|
672
|
+
let parsed;
|
|
673
|
+
try {
|
|
674
|
+
parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
return false; // missing/unreadable → fresh start
|
|
678
|
+
}
|
|
679
|
+
const j = parsed;
|
|
680
|
+
if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
|
|
681
|
+
try {
|
|
682
|
+
fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
/* ignore */
|
|
686
|
+
}
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
this._audit.createdAt = j.createdAt ?? new Date().toISOString();
|
|
690
|
+
this._audit.activeStamp =
|
|
691
|
+
typeof j.activeStamp === "string" ? j.activeStamp : null;
|
|
692
|
+
this._audit.activeIndex =
|
|
693
|
+
Number.isInteger(j.activeIndex) ? j.activeIndex : null;
|
|
694
|
+
this._audit.files =
|
|
695
|
+
j.files && typeof j.files === "object"
|
|
696
|
+
? j.files
|
|
697
|
+
: {};
|
|
698
|
+
this._audit.events = Array.isArray(j.events)
|
|
699
|
+
? j.events.slice(-AUDIT_MAX_EVENTS)
|
|
700
|
+
: [];
|
|
701
|
+
/* Detect another LIVE process on this family before clobbering it. */
|
|
702
|
+
const w = j.writer;
|
|
703
|
+
if (w &&
|
|
704
|
+
typeof w.bootId === "string" &&
|
|
705
|
+
w.bootId !== this._bootId &&
|
|
706
|
+
typeof w.heartbeat === "number" &&
|
|
707
|
+
Date.now() - w.heartbeat < HEARTBEAT_STALE_MS) {
|
|
708
|
+
const err = new Error(`concurrent writer detected (pid ${w.pid}, boot ${w.bootId}); ` +
|
|
709
|
+
"two processes share this log family and will race. " +
|
|
710
|
+
"If running 'node --watch', exclude the logs dir via --watch-path.");
|
|
711
|
+
this.emit("warn", err);
|
|
712
|
+
this._audit.events.push({
|
|
713
|
+
id: node_crypto.randomUUID(),
|
|
714
|
+
t: new Date().toISOString(),
|
|
715
|
+
type: "concurrent-writer",
|
|
716
|
+
otherPid: w.pid,
|
|
717
|
+
otherBoot: w.bootId,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
_saveAudit() {
|
|
723
|
+
if (!this._auditEnabled)
|
|
724
|
+
return;
|
|
725
|
+
try {
|
|
726
|
+
this._pruneAuditFiles();
|
|
727
|
+
this._audit.updatedAt = new Date().toISOString();
|
|
728
|
+
this._audit.writer = {
|
|
729
|
+
pid: process.pid,
|
|
730
|
+
bootId: this._bootId,
|
|
731
|
+
heartbeat: Date.now(),
|
|
732
|
+
};
|
|
733
|
+
const tmp = `${this._auditPath}.tmp`;
|
|
734
|
+
fs.writeFileSync(tmp, JSON.stringify(this._audit, null, 1));
|
|
735
|
+
fs.renameSync(tmp, this._auditPath);
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
this.emit("warn", e);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
_pruneAuditFiles() {
|
|
742
|
+
const files = this._audit.files;
|
|
743
|
+
const keys = Object.keys(files);
|
|
744
|
+
if (keys.length <= AUDIT_MAX_FILES)
|
|
745
|
+
return;
|
|
746
|
+
const activeBase = this._path && path.basename(this._path);
|
|
747
|
+
const lastTouch = (r) => r.archivedAt ?? r.closedAt ?? r.abandonedAt ?? r.openedAt ?? "";
|
|
748
|
+
const stale = keys
|
|
749
|
+
.filter((k) => k !== activeBase && files[k].state !== "active")
|
|
750
|
+
.sort((a, b) => lastTouch(files[a]).localeCompare(lastTouch(files[b])));
|
|
751
|
+
const excess = keys.length - AUDIT_MAX_FILES + 50;
|
|
752
|
+
for (const key of stale.slice(0, excess))
|
|
753
|
+
delete files[key];
|
|
754
|
+
}
|
|
755
|
+
_aevent(type, extra = {}) {
|
|
756
|
+
if (!this._auditEnabled)
|
|
757
|
+
return;
|
|
758
|
+
const events = this._audit.events;
|
|
759
|
+
events.push({ id: node_crypto.randomUUID(), t: new Date().toISOString(), type, ...extra });
|
|
760
|
+
if (events.length > AUDIT_MAX_EVENTS) {
|
|
761
|
+
events.splice(0, events.length - AUDIT_MAX_EVENTS);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
_setActivePointer(index) {
|
|
765
|
+
this._audit.activeStamp = this._curStamp;
|
|
766
|
+
this._audit.activeIndex = index;
|
|
767
|
+
}
|
|
768
|
+
/** Records left `active` by dead processes → `abandoned`. */
|
|
769
|
+
_demoteZombieActives() {
|
|
770
|
+
const activeBase = this._path && path.basename(this._path);
|
|
771
|
+
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
772
|
+
if (rec.state === "active" && name !== activeBase) {
|
|
773
|
+
rec.state = "abandoned";
|
|
774
|
+
rec.abandonedAt = new Date().toISOString();
|
|
775
|
+
this._aevent("abandon", { file: name });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** Heal audit-vs-disk divergence after crashes / external tampering. */
|
|
780
|
+
_reconcileWithDisk() {
|
|
781
|
+
for (const name of Object.keys(this._audit.files)) {
|
|
782
|
+
const rec = this._audit.files[name];
|
|
783
|
+
if (rec.gzipPending)
|
|
784
|
+
continue; // handled by the repair pass
|
|
785
|
+
if (name.endsWith(".gz")) {
|
|
786
|
+
const gzExists = existsSafe(path.join(this.dir, name));
|
|
787
|
+
const rawName = name.slice(0, -3);
|
|
788
|
+
const rawExists = existsSafe(path.join(this.dir, rawName));
|
|
789
|
+
if (gzExists)
|
|
790
|
+
continue;
|
|
791
|
+
if (rawExists) {
|
|
792
|
+
delete this._audit.files[name];
|
|
793
|
+
this._audit.files[rawName] = { ...rec, state: "sealed" };
|
|
794
|
+
this._aevent("archive-regressed", { file: name, backTo: rawName });
|
|
795
|
+
this.emit("warn", new Error(`archive ${name} missing but raw ${rawName} exists`));
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
this._aevent("vanished", { file: name });
|
|
799
|
+
delete this._audit.files[name];
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const rawExists = existsSafe(path.join(this.dir, name));
|
|
803
|
+
const gzExists = existsSafe(path.join(this.dir, `${name}.gz`));
|
|
804
|
+
if (rawExists)
|
|
805
|
+
continue;
|
|
806
|
+
if (gzExists) {
|
|
807
|
+
this._finishArchivedRecord(name, true); // unlink was interrupted
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
this._aevent("vanished", { file: name });
|
|
811
|
+
delete this._audit.files[name];
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
async _repairInterruptedGzips() {
|
|
815
|
+
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
816
|
+
if (!rec.gzipPending)
|
|
817
|
+
continue;
|
|
818
|
+
const src = path.join(this.dir, name);
|
|
819
|
+
const dst = `${src}.gz`;
|
|
820
|
+
if (!existsSafe(src)) {
|
|
821
|
+
rec.gzipPending = false;
|
|
822
|
+
rec.state = "lost";
|
|
823
|
+
this._aevent("gzip-lost", { file: name });
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (existsSafe(dst)) {
|
|
827
|
+
try {
|
|
828
|
+
fs.unlinkSync(src);
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
/* ignore */
|
|
832
|
+
}
|
|
833
|
+
this._finishArchivedRecord(name, true);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
|
|
837
|
+
if (ok) {
|
|
838
|
+
this._finishArchivedRecord(name, true);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
rec.gzipPending = false;
|
|
842
|
+
rec.state = "kept-raw";
|
|
843
|
+
this._aevent("gzip-failed", { file: name });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
|
|
848
|
+
_finishArchivedRecord(rawName, repaired = false) {
|
|
849
|
+
const rec = this._audit.files[rawName];
|
|
850
|
+
if (!rec)
|
|
851
|
+
return;
|
|
852
|
+
rec.gzipPending = false;
|
|
853
|
+
rec.state = "archived";
|
|
854
|
+
rec.archivedAt = new Date().toISOString();
|
|
855
|
+
if (rawName.endsWith(".gz")) {
|
|
856
|
+
this.emit("archive", path.join(this.dir, rawName));
|
|
857
|
+
this._aevent("archive", { file: rawName, ...(repaired && { repaired }) });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const gzName = `${rawName}.gz`;
|
|
861
|
+
delete this._audit.files[rawName];
|
|
862
|
+
this._audit.files[gzName] = rec;
|
|
863
|
+
this.emit("archive", path.join(this.dir, gzName));
|
|
864
|
+
this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Seal raw segments whose period has already passed (crash near midnight,
|
|
868
|
+
* virtual-clock leftovers, untracked files). Disk is the source of truth —
|
|
869
|
+
* manifest-tracked or not, a raw tail nothing will ever write to again
|
|
870
|
+
* must still honour zippedArchive.
|
|
871
|
+
*/
|
|
872
|
+
async _sealOrphanedPastStamps() {
|
|
873
|
+
if (!this.zippedArchive)
|
|
874
|
+
return;
|
|
875
|
+
const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
|
|
876
|
+
"(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
877
|
+
let names = [];
|
|
878
|
+
try {
|
|
879
|
+
names = fs.readdirSync(this.dir);
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
for (const name of names) {
|
|
885
|
+
if (name.endsWith(".gz"))
|
|
886
|
+
continue;
|
|
887
|
+
if (name === (this._path && path.basename(this._path)))
|
|
888
|
+
continue;
|
|
889
|
+
const stamp = stampRx.exec(name)?.[1];
|
|
890
|
+
// lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
|
|
891
|
+
// also guards the current stamp and anything future-dated
|
|
892
|
+
if (!stamp || stamp >= this._curStamp)
|
|
893
|
+
continue;
|
|
894
|
+
const full = path.join(this.dir, name);
|
|
895
|
+
if (!existsSafe(full) || statSizeSafe(full) === 0)
|
|
896
|
+
continue;
|
|
897
|
+
const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
|
|
898
|
+
if (!ok)
|
|
899
|
+
continue;
|
|
900
|
+
if (this._auditEnabled && this._audit.files[name]) {
|
|
901
|
+
// tracked → re-key record name → name.gz (emits archive + journal)
|
|
902
|
+
this._finishArchivedRecord(name, true);
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
// untracked → record it fresh so future reconciles see a truthful state
|
|
906
|
+
const gzName = `${name}.gz`;
|
|
907
|
+
if (this._auditEnabled) {
|
|
908
|
+
this._audit.files[gzName] = {
|
|
909
|
+
state: "archived",
|
|
910
|
+
gzipPending: false,
|
|
911
|
+
archivedAt: new Date().toISOString(),
|
|
912
|
+
};
|
|
913
|
+
this._aevent("seal-past", { file: gzName });
|
|
914
|
+
}
|
|
915
|
+
this.emit("archive", path.join(this.dir, gzName));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/* ==================================================================
|
|
920
|
+
* Retention
|
|
921
|
+
* ================================================================ */
|
|
811
922
|
async _retire() {
|
|
812
|
-
|
|
923
|
+
const policy = this.maxFiles;
|
|
924
|
+
if (!policy)
|
|
813
925
|
return;
|
|
814
926
|
const activeBase = this._path && path.basename(this._path);
|
|
815
|
-
const activeStem = activeBase
|
|
816
|
-
const
|
|
927
|
+
const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
928
|
+
const reserved = [
|
|
817
929
|
path.basename(this._auditPath),
|
|
818
|
-
path.basename(this._auditPath)
|
|
819
|
-
path.basename(this._auditPath)
|
|
930
|
+
`${path.basename(this._auditPath)}.tmp`,
|
|
931
|
+
`${path.basename(this._auditPath)}.corrupt`,
|
|
820
932
|
];
|
|
821
933
|
try {
|
|
822
|
-
const
|
|
934
|
+
const dirEntries = await fs.promises.readdir(this.dir);
|
|
935
|
+
const candidates = dirEntries.filter((n) => this._rxActive.test(n) &&
|
|
823
936
|
n !== activeBase &&
|
|
824
937
|
!(activeStem && n.startsWith(activeStem)) &&
|
|
825
|
-
!
|
|
826
|
-
const
|
|
827
|
-
for (const
|
|
828
|
-
const full = path.join(this.dir,
|
|
938
|
+
!reserved.includes(n));
|
|
939
|
+
const stats = [];
|
|
940
|
+
for (const name of candidates) {
|
|
941
|
+
const full = path.join(this.dir, name);
|
|
829
942
|
try {
|
|
830
|
-
|
|
943
|
+
stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
|
|
831
944
|
}
|
|
832
945
|
catch {
|
|
833
|
-
/*
|
|
946
|
+
/* raced deletion */
|
|
834
947
|
}
|
|
835
948
|
}
|
|
836
|
-
|
|
949
|
+
stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
837
950
|
const kill = new Set();
|
|
838
|
-
if (
|
|
839
|
-
const
|
|
840
|
-
for (const f of
|
|
841
|
-
if (f.
|
|
951
|
+
if (policy.days != null) {
|
|
952
|
+
const cutoff = this._now() - policy.days * 86_400_000;
|
|
953
|
+
for (const f of stats)
|
|
954
|
+
if (f.mtimeMs < cutoff)
|
|
842
955
|
kill.add(f.full);
|
|
843
956
|
}
|
|
844
|
-
if (
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
957
|
+
if (policy.count != null) {
|
|
958
|
+
const survivors = stats.filter((f) => !kill.has(f.full));
|
|
959
|
+
const excessCount = survivors.length - policy.count;
|
|
960
|
+
for (let i = 0; i < excessCount; i++)
|
|
961
|
+
kill.add(survivors[i].full);
|
|
848
962
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
963
|
+
if (kill.size === 0)
|
|
964
|
+
return;
|
|
965
|
+
for (const file of kill) {
|
|
966
|
+
await fs.promises.unlink(file).catch(() => { });
|
|
852
967
|
if (this._auditEnabled) {
|
|
853
|
-
delete this._audit.files[path.basename(
|
|
854
|
-
this._aevent("deleted", { file: path.basename(
|
|
968
|
+
delete this._audit.files[path.basename(file)];
|
|
969
|
+
this._aevent("deleted", { file: path.basename(file) });
|
|
855
970
|
}
|
|
856
|
-
this.emit("deleted",
|
|
857
|
-
removed = true;
|
|
971
|
+
this.emit("deleted", file);
|
|
858
972
|
}
|
|
859
|
-
|
|
860
|
-
this._saveAudit();
|
|
973
|
+
this._saveAudit();
|
|
861
974
|
}
|
|
862
975
|
catch (e) {
|
|
863
976
|
this.emit("warn", e);
|
|
864
977
|
}
|
|
865
978
|
}
|
|
866
979
|
}
|
|
867
|
-
|
|
868
|
-
*
|
|
869
|
-
*
|
|
980
|
+
/**
|
|
981
|
+
* Convenience factory mirroring common rotator APIs.
|
|
982
|
+
*
|
|
983
|
+
* @example
|
|
984
|
+
* const log = createStream({ filename: "logs/app-%DATE%.log" });
|
|
985
|
+
*/
|
|
870
986
|
function createStream(options) {
|
|
871
987
|
return new RotateFileStream(options);
|
|
872
988
|
}
|