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