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