"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createStream = exports.RotatingFileStream = void 0; const child_process_1 = require("child_process"); const zlib_1 = require("zlib"); const stream_1 = require("stream"); const fs_1 = require("fs"); const path_1 = require("path"); const util_1 = require("util"); const timers_1 = require("timers"); class RotatingFileStreamError extends Error { constructor() { super("Too many destination file attempts"); this.code = "RFS-TOO-MANY"; } } class RotatingFileStream extends stream_1.Writable { constructor(generator, options) { const { encoding, history, maxFiles, maxSize, path } = options; super({ decodeStrings: true, defaultEncoding: encoding }); this.createGzip = zlib_1.createGzip; this.exec = child_process_1.exec; this.filename = path + generator(null); this.fsClose = fs_1.close; this.fsCreateReadStream = fs_1.createReadStream; this.fsCreateWriteStream = fs_1.createWriteStream; this.fsMkdir = fs_1.mkdir; this.fsOpen = fs_1.open; this.fsReadFile = fs_1.readFile; this.fsRename = fs_1.rename; this.fsStat = fs_1.stat; this.fsUnlink = fs_1.unlink; this.fsWrite = fs_1.write; this.fsWriteFile = fs_1.writeFile; this.generator = generator; this.maxTimeout = 2147483640; this.options = options; if (maxFiles || maxSize) options.history = path + (history ? history : this.generator(null) + ".txt"); this.on("close", () => (this.finished ? null : this.emit("finish"))); this.on("finish", () => (this.finished = this.clear())); process.nextTick(() => this.init(error => { this.error = error; if (this.opened) this.opened(); else if (this.error) this.emit("error", error); })); } _destroy(error, callback) { const destroyer = () => { this.clear(); this.reclose(() => { }); }; if (this.stream) destroyer(); else this.destroyer = destroyer; callback(error); } _final(callback) { if (this.stream) return this.stream.end(callback); callback(); } _write(chunk, encoding, callback) { this.rewrite([{ chunk, encoding }], 0, callback); } _writev(chunks, callback) { this.rewrite(chunks, 0, callback); } rewrite(chunks, index, callback) { const destroy = (error) => { this.destroy(error); return callback(); }; const rewrite = () => { if (this.destroyed) return callback(this.error); if (this.error) return destroy(this.error); if (!this.stream) { this.opened = rewrite; return; } const done = (error) => { if (error) return destroy(error); if (++index !== chunks.length) return this.rewrite(chunks, index, callback); callback(); }; this.size += chunks[index].chunk.length; this.stream.write(chunks[index].chunk, chunks[index].encoding, (error) => { if (error) return done(error); if (this.options.size && this.size >= this.options.size) return this.rotate(done); done(); }); if (this.options.teeToStdout && !process.stdout.destroyed) this.writeToStdOut(chunks[index].chunk, chunks[index].encoding); }; if (this.stream) { return this.fsStat(this.filename, (error) => { if (!error) return rewrite(); if (error.code !== "ENOENT") return destroy(error); this.reclose(() => this.reopen(false, 0, () => rewrite())); }); } this.opened = rewrite; } writeToStdOut(buffer, encoding) { process.stdout.write(buffer, encoding); } init(callback) { const { immutable, initialRotation, interval, size } = this.options; if (immutable) return this.immutate(true, callback); this.fsStat(this.filename, (error, stats) => { if (error) return error.code === "ENOENT" ? this.reopen(false, 0, callback) : callback(error); if (!stats.isFile()) return callback(new Error(`Can't write on: ${this.filename} (it is not a file)`)); if (initialRotation) { this.intervalBounds(this.now()); const prev = this.prev; this.intervalBounds(new Date(stats.mtime.getTime())); if (prev !== this.prev) return this.rotate(callback); } this.size = stats.size; if (!size || stats.size < size) return this.reopen(false, stats.size, callback); if (interval) this.intervalBounds(this.now()); this.rotate(callback); }); } makePath(name, callback) { const dir = (0, path_1.parse)(name).dir; this.fsMkdir(dir, (error) => { if (error) { if (error.code === "ENOENT") return this.makePath(dir, (error) => (error ? callback(error) : this.makePath(name, callback))); if (error.code === "EEXIST") return callback(); return callback(error); } callback(); }); } reopen(retry, size, callback) { const options = { flags: "a" }; if ("mode" in this.options) options.mode = this.options.mode; let called; const stream = this.fsCreateWriteStream(this.filename, options); const end = (error) => { if (called) { this.error = error; return; } called = true; this.stream = stream; if (this.opened) { process.nextTick(this.opened); this.opened = null; } if (this.destroyer) process.nextTick(this.destroyer); callback(error); }; stream.once("open", () => { this.size = size; end(); this.interval(); this.emit("open", this.filename); }); stream.once("error", (error) => error.code !== "ENOENT" || retry ? end(error) : this.makePath(this.filename, (error) => (error ? end(error) : this.reopen(true, size, callback)))); } reclose(callback) { const { stream } = this; if (!stream) return callback(); this.stream = null; stream.once("finish", callback); stream.end(); } now() { return new Date(); } rotate(callback) { const { immutable, rotate } = this.options; this.size = 0; this.rotation = this.now(); this.clear(); this.reclose(() => (rotate ? this.classical(rotate, callback) : immutable ? this.immutate(false, callback) : this.move(callback))); this.emit("rotation"); } findName(tmp, callback, index) { if (!index) index = 1; const { interval, path, intervalBoundary } = this.options; let filename = `${this.filename}.${index}.rfs.tmp`; if (index >= 1000) return callback(new RotatingFileStreamError()); if (!tmp) { try { filename = path + this.generator(interval && intervalBoundary ? new Date(this.prev) : this.rotation, index); } catch (e) { return callback(e); } } this.fsStat(filename, error => { if (!error || error.code !== "ENOENT") return this.findName(tmp, callback, index + 1); callback(null, filename); }); } move(callback) { const { compress } = this.options; let filename; const open = (error) => { if (error) return callback(error); this.rotated(filename, callback); }; this.findName(false, (error, found) => { if (error) return callback(error); filename = found; this.touch(filename, false, (error) => { if (error) return callback(error); if (compress) return this.compress(filename, open); this.fsRename(this.filename, filename, open); }); }); } touch(filename, retry, callback) { this.fsOpen(filename, "a", parseInt("666", 8), (error, fd) => { if (error) { if (error.code !== "ENOENT" || retry) return callback(error); return this.makePath(filename, error => { if (error) return callback(error); this.touch(filename, true, callback); }); } return this.fsClose(fd, (error) => { if (error) return callback(error); this.fsUnlink(filename, (error) => { if (error) this.emit("warning", error); callback(); }); }); }); } classical(count, callback) { const { compress, path, rotate } = this.options; let prevName; let thisName; if (rotate === count) delete this.rotatedName; const open = (error) => { if (error) return callback(error); this.rotated(this.rotatedName, callback); }; try { prevName = count === 1 ? this.filename : path + this.generator(count - 1); thisName = path + this.generator(count); } catch (e) { return callback(e); } const next = count === 1 ? open : () => this.classical(count - 1, callback); const move = () => { if (count === 1 && compress) return this.compress(thisName, open); this.fsRename(prevName, thisName, (error) => { if (!error) return next(); if (error.code !== "ENOENT") return callback(error); this.makePath(thisName, (error) => { if (error) return callback(error); this.fsRename(prevName, thisName, (error) => (error ? callback(error) : next())); }); }); }; this.fsStat(prevName, (error) => { if (error) { if (error.code !== "ENOENT") return callback(error); return next(); } if (!this.rotatedName) this.rotatedName = thisName; move(); }); } clear() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } return true; } intervalBoundsBig(now) { const year = now.getFullYear(); let month = now.getMonth(); let day = now.getDate(); let hours = now.getHours(); const { num, unit } = this.options.interval; if (unit === "M") { day = 1; hours = 0; } else if (unit === "d") hours = 0; else hours = parseInt((hours / num), 10) * num; this.prev = new Date(year, month, day, hours, 0, 0, 0).getTime(); if (unit === "M") month += num; else if (unit === "d") day += num; else hours += num; this.next = new Date(year, month, day, hours, 0, 0, 0).getTime(); } intervalBounds(now) { const unit = this.options.interval.unit; if (unit === "M" || unit === "d" || unit === "h") this.intervalBoundsBig(now); else { let period = 1000 * this.options.interval.num; if (unit === "m") period *= 60; this.prev = parseInt((now.getTime() / period), 10) * period; this.next = this.prev + period; } return new Date(this.prev); } interval() { if (!this.options.interval) return; this.intervalBounds(this.now()); const set = () => { const time = this.next - this.now().getTime(); if (time <= 0) return this.rotate(error => (this.error = error)); this.timer = (0, timers_1.setTimeout)(set, time > this.maxTimeout ? this.maxTimeout : time); this.timer.unref(); }; set(); } compress(filename, callback) { const { compress } = this.options; const done = (error) => { if (error) return callback(error); this.fsUnlink(this.filename, callback); }; if (typeof compress === "function") this.external(filename, done); else this.gzip(filename, done); } external(filename, callback) { const compress = this.options.compress; let cont; try { cont = compress(this.filename, filename); } catch (e) { return callback(e); } this.findName(true, (error, found) => { if (error) return callback(error); this.fsOpen(found, "w", 0o777, (error, fd) => { if (error) return callback(error); const unlink = (error) => { this.fsUnlink(found, (error2) => { if (error2) this.emit("warning", error2); callback(error); }); }; this.fsWrite(fd, cont, (error) => { this.fsClose(fd, (error2) => { if (error) { if (error2) this.emit("warning", error2); return unlink(error); } if (error2) return unlink(error2); if (found.indexOf(path_1.sep) === -1) found = `.${path_1.sep}${found}`; this.exec(`sh "${found}"`, unlink); }); }); }); }); } gzip(filename, callback) { const { mode } = this.options; const options = mode ? { mode } : {}; const inp = this.fsCreateReadStream(this.filename, {}); const out = this.fsCreateWriteStream(filename, options); const zip = this.createGzip(); [inp, out, zip].map(stream => stream.once("error", callback)); out.once("finish", callback); inp.pipe(zip).pipe(out); } rotated(filename, callback) { const { maxFiles, maxSize } = this.options; const open = (error) => { if (error) return callback(error); this.reopen(false, 0, callback); this.emit("rotated", filename); }; if (maxFiles || maxSize) return this.history(filename, open); open(); } history(filename, callback) { const { history } = this.options; this.fsReadFile(history, "utf8", (error, data) => { if (error) { if (error.code !== "ENOENT") return callback(error); return this.historyGather([filename], 0, [], callback); } const files = data.split("\n"); files.push(filename); this.historyGather(files, 0, [], callback); }); } historyGather(files, index, res, callback) { if (index === files.length) return this.historyCheckFiles(res, callback); this.fsStat(files[index], (error, stats) => { if (error) { if (error.code !== "ENOENT") return callback(error); } else if (stats.isFile()) { res.push({ name: files[index], size: stats.size, time: stats.ctime.getTime() }); } else this.emit("warning", new Error(`File '${files[index]}' contained in history is not a regular file`)); this.historyGather(files, index + 1, res, callback); }); } historyRemove(files, size, callback) { const file = files.shift(); this.fsUnlink(file.name, (error) => { if (error) return callback(error); this.emit("removed", file.name, !size); callback(); }); } historyCheckFiles(files, callback) { const { maxFiles } = this.options; files.sort((a, b) => a.time - b.time); if (!maxFiles || files.length <= maxFiles) return this.historyCheckSize(files, callback); this.historyRemove(files, false, (error) => (error ? callback(error) : this.historyCheckFiles(files, callback))); } historyCheckSize(files, callback) { const { maxSize } = this.options; let size = 0; if (!maxSize) return this.historyWrite(files, callback); files.map(e => (size += e.size)); if (size <= maxSize) return this.historyWrite(files, callback); this.historyRemove(files, true, (error) => (error ? callback(error) : this.historyCheckSize(files, callback))); } historyWrite(files, callback) { this.fsWriteFile(this.options.history, files.map(e => e.name).join("\n") + "\n", "utf8", (error) => { if (error) return callback(error); this.emit("history"); callback(); }); } immutate(first, callback, index, now) { if (!index) { index = 1; now = this.now(); } if (index >= 1001) return callback(new RotatingFileStreamError()); try { this.filename = this.options.path + this.generator(now, index); } catch (e) { return callback(e); } const open = (size, callback) => { if (first) { this.last = this.filename; return this.reopen(false, size, callback); } this.rotated(this.last, (error) => { this.last = this.filename; callback(error); }); }; this.fsStat(this.filename, (error, stats) => { const { size } = this.options; if (error) { if (error.code === "ENOENT") return open(0, callback); return callback(error); } if (!stats.isFile()) return callback(new Error(`Can't write on: '${this.filename}' (it is not a file)`)); if (size && stats.size >= size) return this.immutate(first, callback, index + 1, now); open(stats.size, callback); }); } } exports.RotatingFileStream = RotatingFileStream; function buildNumberCheck(field) { return (type, options, value) => { const converted = parseInt(value, 10); if (type !== "number" || converted !== value || converted <= 0) throw new Error(`'${field}' option must be a positive integer number`); }; } function buildStringCheck(field, check) { return (type, options, value) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.${field}' type: ${type}`); options[field] = check(value); }; } function checkMeasure(value, what, units) { const ret = {}; ret.num = parseInt(value, 10); if (isNaN(ret.num)) throw new Error(`Unknown 'options.${what}' format: ${value}`); if (ret.num <= 0) throw new Error(`A positive integer number is expected for 'options.${what}'`); ret.unit = value.replace(/^[ 0]*/g, "").substr((ret.num + "").length, 1); if (ret.unit.length === 0) throw new Error(`Missing unit for 'options.${what}'`); if (!units[ret.unit]) throw new Error(`Unknown 'options.${what}' unit: ${ret.unit}`); return ret; } const intervalUnits = { M: true, d: true, h: true, m: true, s: true }; function checkIntervalUnit(ret, unit, amount) { if (parseInt((amount / ret.num), 10) * ret.num !== amount) throw new Error(`An integer divider of ${amount} is expected as ${unit} for 'options.interval'`); } function checkInterval(value) { const ret = checkMeasure(value, "interval", intervalUnits); switch (ret.unit) { case "h": checkIntervalUnit(ret, "hours", 24); break; case "m": checkIntervalUnit(ret, "minutes", 60); break; case "s": checkIntervalUnit(ret, "seconds", 60); break; } return ret; } const sizeUnits = { B: true, G: true, K: true, M: true }; function checkSize(value) { const ret = checkMeasure(value, "size", sizeUnits); if (ret.unit === "K") return ret.num * 1024; if (ret.unit === "M") return ret.num * 1048576; if (ret.unit === "G") return ret.num * 1073741824; return ret.num; } const checks = { compress: (type, options, value) => { if (!value) throw new Error("A value for 'options.compress' must be specified"); if (type === "boolean") return (options.compress = (source, dest) => `cat ${source} | gzip -c9 > ${dest}`); if (type === "function") return; if (type !== "string") throw new Error(`Don't know how to handle 'options.compress' type: ${type}`); if (value !== "gzip") throw new Error(`Don't know how to handle compression method: ${value}`); }, encoding: (type, options, value) => new util_1.TextDecoder(value), history: (type) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.history' type: ${type}`); }, immutable: () => { }, initialRotation: () => { }, interval: buildStringCheck("interval", checkInterval), intervalBoundary: () => { }, maxFiles: buildNumberCheck("maxFiles"), maxSize: buildStringCheck("maxSize", checkSize), mode: () => { }, path: (type, options, value) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.path' type: ${type}`); if (value[value.length - 1] !== path_1.sep) options.path = value + path_1.sep; }, rotate: buildNumberCheck("rotate"), size: buildStringCheck("size", checkSize), teeToStdout: () => { } }; function checkOpts(options) { const ret = {}; for (const opt in options) { const value = options[opt]; const type = typeof value; if (!(opt in checks)) throw new Error(`Unknown option: ${opt}`); ret[opt] = options[opt]; checks[opt](type, ret, value); } if (!ret.path) ret.path = ""; if (!ret.interval) { delete ret.immutable; delete ret.initialRotation; delete ret.intervalBoundary; } if (ret.rotate) { delete ret.history; delete ret.immutable; delete ret.maxFiles; delete ret.maxSize; delete ret.intervalBoundary; } if (ret.immutable) delete ret.compress; if (!ret.intervalBoundary) delete ret.initialRotation; return ret; } function createClassical(filename) { return (index) => (index ? `${filename}.${index}` : filename); } function createGenerator(filename) { const pad = (num) => (num > 9 ? "" : "0") + num; return (time, index) => { if (!time) return filename; const month = time.getFullYear() + "" + pad(time.getMonth() + 1); const day = pad(time.getDate()); const hour = pad(time.getHours()); const minute = pad(time.getMinutes()); return month + day + "-" + hour + minute + "-" + pad(index) + "-" + filename; }; } function createStream(filename, options) { if (typeof options === "undefined") options = {}; else if (typeof options !== "object") throw new Error(`The "options" argument must be of type object. Received type ${typeof options}`); const opts = checkOpts(options); let generator; if (typeof filename === "string") generator = options.rotate ? createClassical(filename) : createGenerator(filename); else if (typeof filename === "function") generator = filename; else throw new Error(`The "filename" argument must be one of type string or function. Received type ${typeof filename}`); return new RotatingFileStream(generator, opts); } exports.createStream = createStream;