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
JavaScript
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);
};