UNPKG

rotating-file-stream

Version:

Opens a stream.Writable to a file rotated by interval and/or size. A logrotate alternative.

189 lines (188 loc) 7.3 kB
import { sep } from "node:path"; import { Writable } from "node:stream"; import { TextDecoder } from "node:util"; export class RotatingFileStreamError extends Error { code = "RFS-TOO-MANY"; constructor() { super("Too many destination file attempts"); } } /* eslint-enable @typescript-eslint/no-unused-vars */ // Dummy implementation: only stores constructor args for now. export class RotatingFileStream extends Writable { generator; options; constructor(generator, options) { super({ decodeStrings: true, defaultEncoding: options.encoding }); this.generator = generator; this.options = options; } } const sizeUnits = { B: true, G: true, K: true, M: true }; const intervalUnits = { d: true, h: true, m: true, M: true, s: true }; const noopChecker = () => undefined; const assertPositiveInteger = (field, type, value) => { const converted = parseInt(value, 10); if (type !== "number" || converted !== value || converted <= 0) throw new Error(`'${field}' option must be a positive integer number`); }; const parseMeasure = (value, what, units) => { const num = parseInt(value, 10); if (Number.isNaN(num)) throw new Error(`Unknown 'options.${what}' format: ${value}`); if (num <= 0) throw new Error(`A positive integer number is expected for 'options.${what}'`); const unit = value.replace(/^[ 0]*/g, "").slice(String(num).length, String(num).length + 1); if (!unit) throw new Error(`Missing unit for 'options.${what}'`); if (!units[unit]) throw new Error(`Unknown 'options.${what}' unit: ${unit}`); return { num, unit }; }; const parseSize = (value, what) => { const { num, unit } = parseMeasure(value, what, sizeUnits); if (unit === "K") return num * 1024; if (unit === "M") return num * 1048576; if (unit === "G") return num * 1073741824; return num; }; const assertIntervalDivider = (num, unit, amount) => { if (Math.trunc(amount / num) * num !== amount) throw new Error(`An integer divider of ${amount} is expected as ${unit} for 'options.interval'`); }; const parseInterval = (value) => { const ret = parseMeasure(value, "interval", intervalUnits); if (ret.unit === "h") assertIntervalDivider(ret.num, "hours", 24); if (ret.unit === "m") assertIntervalDivider(ret.num, "minutes", 60); if (ret.unit === "s") assertIntervalDivider(ret.num, "seconds", 60); return ret; }; const checkers = { compress: (type, options, value) => { if (value === false) return; if (!value) throw new Error("A value for 'options.compress' must be specified"); if (type === "boolean") { options.compress = (source, dest) => `cat ${source} | gzip -c9 > ${dest}`; return; } if (type === "function") return; if (type !== "string") throw new Error(`Don't know how to handle 'options.compress' type: ${type}`); const method = value; if (method !== "gzip") throw new Error(`Don't know how to handle compression method: ${method}`); }, encoding: (_type, _options, value) => { new TextDecoder(value); }, history: type => { if (type !== "string") throw new Error(`Don't know how to handle 'options.history' type: ${type}`); }, immutable: noopChecker, initialRotation: noopChecker, interval: (type, options, value) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.interval' type: ${type}`); options.interval = parseInterval(value); }, intervalBoundary: noopChecker, intervalUTC: noopChecker, maxFiles: (type, _options, value) => assertPositiveInteger("maxFiles", type, value), maxSize: (type, options, value) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.maxSize' type: ${type}`); options.maxSize = parseSize(value, "maxSize"); }, mode: noopChecker, omitExtension: noopChecker, path: (type, options, value) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.path' type: ${type}`); const path = value; if (!path.endsWith(sep)) options.path = path + sep; }, rotate: (type, _options, value) => assertPositiveInteger("rotate", type, value), size: (type, options, value) => { if (type !== "string") throw new Error(`Don't know how to handle 'options.size' type: ${type}`); options.size = parseSize(value, "size"); }, teeToStdout: noopChecker }; const checkOpts = (options) => { const ret = {}; for (const opt of Object.keys(options)) { const value = options[opt]; const type = typeof value; if (!(opt in checkers)) throw new Error(`Unknown option: ${opt}`); ret[opt] = value; checkers[opt](type, ret, value); } if (!ret.path) ret.path = ""; if (!ret.interval) { delete ret.immutable; delete ret.initialRotation; delete ret.intervalBoundary; delete ret.intervalUTC; } if (ret.rotate) { delete ret.history; delete ret.immutable; delete ret.maxFiles; delete ret.maxSize; delete ret.intervalBoundary; delete ret.intervalUTC; } if (ret.immutable) delete ret.compress; if (!ret.intervalBoundary) delete ret.initialRotation; return ret; }; const pad = (num) => `${num > 9 ? "" : "0"}${num}`; const createClassical = (filename, compress, omitExtension) => (index) => index ? `${filename}.${index}${compress && !omitExtension ? ".gz" : ""}` : filename; const createGenerator = (filename, compress, omitExtension) => (time, index) => { if (!time) return filename; const date = time instanceof Date ? time : new Date(time); const month = `${date.getFullYear()}${pad(date.getMonth() + 1)}`; const day = pad(date.getDate()); const hour = pad(date.getHours()); const minute = pad(date.getMinutes()); return `${month}${day}-${hour}${minute}-${pad(index ?? 0)}-${filename}${compress && !omitExtension ? ".gz" : ""}`; }; export const createStream = (filename, options) => { const rawOptions = options; if (rawOptions === undefined) options = {}; else if (typeof rawOptions !== "object" || rawOptions === null) throw new Error(`The "options" argument must be of type object. Received type ${typeof rawOptions}`); else options = rawOptions; const opts = checkOpts(options); const { compress, omitExtension } = opts; let generator; if (typeof filename === "string") { generator = opts.rotate ? createClassical(filename, Boolean(compress), Boolean(omitExtension)) : createGenerator(filename, Boolean(compress), Boolean(omitExtension)); } 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); };