RollingFileWriteStream.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. const debug = require("debug")("streamroller:RollingFileWriteStream");
  2. const fs = require("fs-extra");
  3. const path = require("path");
  4. const os = require("os");
  5. const newNow = require("./now");
  6. const format = require("date-format");
  7. const { Writable } = require("stream");
  8. const fileNameFormatter = require("./fileNameFormatter");
  9. const fileNameParser = require("./fileNameParser");
  10. const moveAndMaybeCompressFile = require("./moveAndMaybeCompressFile");
  11. const deleteFiles = fileNames => {
  12. debug(`deleteFiles: files to delete: ${fileNames}`);
  13. return Promise.all(fileNames.map(f => fs.unlink(f).catch((e) => {
  14. debug(`deleteFiles: error when unlinking ${f}, ignoring. Error was ${e}`);
  15. })));
  16. };
  17. /**
  18. * RollingFileWriteStream is mainly used when writing to a file rolling by date or size.
  19. * RollingFileWriteStream inherits from stream.Writable
  20. */
  21. class RollingFileWriteStream extends Writable {
  22. /**
  23. * Create a RollingFileWriteStream
  24. * @constructor
  25. * @param {string} filePath - The file path to write.
  26. * @param {object} options - The extra options
  27. * @param {number} options.numToKeep - The max numbers of files to keep.
  28. * @param {number} options.maxSize - The maxSize one file can reach. Unit is Byte.
  29. * This should be more than 1024. The default is 0.
  30. * If not specified or 0, then no log rolling will happen.
  31. * @param {string} options.mode - The mode of the files. The default is '0600'. Refer to stream.writable for more.
  32. * @param {string} options.flags - The default is 'a'. Refer to stream.flags for more.
  33. * @param {boolean} options.compress - Whether to compress backup files.
  34. * @param {boolean} options.keepFileExt - Whether to keep the file extension.
  35. * @param {string} options.pattern - The date string pattern in the file name.
  36. * @param {boolean} options.alwaysIncludePattern - Whether to add date to the name of the first file.
  37. */
  38. constructor(filePath, options) {
  39. debug(`constructor: creating RollingFileWriteStream. path=${filePath}`);
  40. if (typeof filePath !== "string" || filePath.length === 0) {
  41. throw new Error(`Invalid filename: ${filePath}`);
  42. } else if (filePath.endsWith(path.sep)) {
  43. throw new Error(`Filename is a directory: ${filePath}`);
  44. } else {
  45. // handle ~ expansion: https://github.com/nodejs/node/issues/684
  46. // exclude ~ and ~filename as these can be valid files
  47. filePath = filePath.replace(new RegExp(`^~(?=${path.sep}.+)`), os.homedir());
  48. }
  49. super(options);
  50. this.options = this._parseOption(options);
  51. this.fileObject = path.parse(filePath);
  52. if (this.fileObject.dir === "") {
  53. this.fileObject = path.parse(path.join(process.cwd(), filePath));
  54. }
  55. this.fileFormatter = fileNameFormatter({
  56. file: this.fileObject,
  57. alwaysIncludeDate: this.options.alwaysIncludePattern,
  58. needsIndex: this.options.maxSize < Number.MAX_SAFE_INTEGER,
  59. compress: this.options.compress,
  60. keepFileExt: this.options.keepFileExt,
  61. fileNameSep: this.options.fileNameSep
  62. });
  63. this.fileNameParser = fileNameParser({
  64. file: this.fileObject,
  65. keepFileExt: this.options.keepFileExt,
  66. pattern: this.options.pattern,
  67. fileNameSep: this.options.fileNameSep
  68. });
  69. this.state = {
  70. currentSize: 0
  71. };
  72. if (this.options.pattern) {
  73. this.state.currentDate = format(this.options.pattern, newNow());
  74. }
  75. this.filename = this.fileFormatter({
  76. index: 0,
  77. date: this.state.currentDate
  78. });
  79. if (["a", "a+", "as", "as+"].includes(this.options.flags)) {
  80. this._setExistingSizeAndDate();
  81. }
  82. debug(
  83. `constructor: create new file ${this.filename}, state=${JSON.stringify(
  84. this.state
  85. )}`
  86. );
  87. this._renewWriteStream();
  88. }
  89. _setExistingSizeAndDate() {
  90. try {
  91. const stats = fs.statSync(this.filename);
  92. this.state.currentSize = stats.size;
  93. if (this.options.pattern) {
  94. this.state.currentDate = format(this.options.pattern, stats.mtime);
  95. }
  96. } catch (e) {
  97. //file does not exist, that's fine - move along
  98. return;
  99. }
  100. }
  101. _parseOption(rawOptions) {
  102. const defaultOptions = {
  103. maxSize: 0,
  104. numToKeep: Number.MAX_SAFE_INTEGER,
  105. encoding: "utf8",
  106. mode: parseInt("0600", 8),
  107. flags: "a",
  108. compress: false,
  109. keepFileExt: false,
  110. alwaysIncludePattern: false
  111. };
  112. const options = Object.assign({}, defaultOptions, rawOptions);
  113. if (!options.maxSize) {
  114. delete options.maxSize;
  115. } else if (options.maxSize <= 0) {
  116. throw new Error(`options.maxSize (${options.maxSize}) should be > 0`);
  117. }
  118. // options.numBackups will supercede options.numToKeep
  119. if (options.numBackups || options.numBackups === 0) {
  120. if (options.numBackups < 0) {
  121. throw new Error(`options.numBackups (${options.numBackups}) should be >= 0`);
  122. } else if (options.numBackups >= Number.MAX_SAFE_INTEGER) {
  123. // to cater for numToKeep (include the hot file) at Number.MAX_SAFE_INTEGER
  124. throw new Error(`options.numBackups (${options.numBackups}) should be < Number.MAX_SAFE_INTEGER`);
  125. } else {
  126. options.numToKeep = options.numBackups + 1;
  127. }
  128. } else if (options.numToKeep <= 0) {
  129. throw new Error(`options.numToKeep (${options.numToKeep}) should be > 0`);
  130. }
  131. debug(
  132. `_parseOption: creating stream with option=${JSON.stringify(options)}`
  133. );
  134. return options;
  135. }
  136. _final(callback) {
  137. this.currentFileStream.end("", this.options.encoding, callback);
  138. }
  139. _write(chunk, encoding, callback) {
  140. this._shouldRoll().then(() => {
  141. debug(
  142. `_write: writing chunk. ` +
  143. `file=${this.currentFileStream.path} ` +
  144. `state=${JSON.stringify(this.state)} ` +
  145. `chunk=${chunk}`
  146. );
  147. this.currentFileStream.write(chunk, encoding, e => {
  148. this.state.currentSize += chunk.length;
  149. callback(e);
  150. });
  151. });
  152. }
  153. async _shouldRoll() {
  154. if (this._dateChanged() || this._tooBig()) {
  155. debug(
  156. `_shouldRoll: rolling because dateChanged? ${this._dateChanged()} or tooBig? ${this._tooBig()}`
  157. );
  158. await this._roll();
  159. }
  160. }
  161. _dateChanged() {
  162. return (
  163. this.state.currentDate &&
  164. this.state.currentDate !== format(this.options.pattern, newNow())
  165. );
  166. }
  167. _tooBig() {
  168. return this.state.currentSize >= this.options.maxSize;
  169. }
  170. _roll() {
  171. debug(`_roll: closing the current stream`);
  172. return new Promise((resolve, reject) => {
  173. this.currentFileStream.end("", this.options.encoding, () => {
  174. this._moveOldFiles()
  175. .then(resolve)
  176. .catch(reject);
  177. });
  178. });
  179. }
  180. async _moveOldFiles() {
  181. const files = await this._getExistingFiles();
  182. const todaysFiles = this.state.currentDate
  183. ? files.filter(f => f.date === this.state.currentDate)
  184. : files;
  185. for (let i = todaysFiles.length; i >= 0; i--) {
  186. debug(`_moveOldFiles: i = ${i}`);
  187. const sourceFilePath = this.fileFormatter({
  188. date: this.state.currentDate,
  189. index: i
  190. });
  191. const targetFilePath = this.fileFormatter({
  192. date: this.state.currentDate,
  193. index: i + 1
  194. });
  195. const moveAndCompressOptions = {
  196. compress: this.options.compress && i === 0,
  197. mode: this.options.mode
  198. };
  199. await moveAndMaybeCompressFile(
  200. sourceFilePath,
  201. targetFilePath,
  202. moveAndCompressOptions
  203. );
  204. }
  205. this.state.currentSize = 0;
  206. this.state.currentDate = this.state.currentDate
  207. ? format(this.options.pattern, newNow())
  208. : null;
  209. debug(
  210. `_moveOldFiles: finished rolling files. state=${JSON.stringify(
  211. this.state
  212. )}`
  213. );
  214. this._renewWriteStream();
  215. // wait for the file to be open before cleaning up old ones,
  216. // otherwise the daysToKeep calculations can be off
  217. await new Promise((resolve, reject) => {
  218. this.currentFileStream.write("", "utf8", () => {
  219. this._clean()
  220. .then(resolve)
  221. .catch(reject);
  222. });
  223. });
  224. }
  225. // Sorted from the oldest to the latest
  226. async _getExistingFiles() {
  227. const files = await fs.readdir(this.fileObject.dir)
  228. .catch( /* istanbul ignore next: will not happen on windows */ () => []);
  229. debug(`_getExistingFiles: files=${files}`);
  230. const existingFileDetails = files
  231. .map(n => this.fileNameParser(n))
  232. .filter(n => n);
  233. const getKey = n =>
  234. (n.timestamp ? n.timestamp : newNow().getTime()) - n.index;
  235. existingFileDetails.sort((a, b) => getKey(a) - getKey(b));
  236. return existingFileDetails;
  237. }
  238. _renewWriteStream() {
  239. const filePath = this.fileFormatter({
  240. date: this.state.currentDate,
  241. index: 0
  242. });
  243. // attempt to create the directory
  244. const mkdir = (dir) => {
  245. try {
  246. return fs.mkdirSync(dir, { recursive: true });
  247. }
  248. // backward-compatible fs.mkdirSync for nodejs pre-10.12.0 (without recursive option)
  249. catch (e) {
  250. // recursive creation of parent first
  251. if (e.code === "ENOENT") {
  252. mkdir(path.dirname(dir));
  253. return mkdir(dir);
  254. }
  255. // throw error for all except EEXIST and EROFS (read-only filesystem)
  256. if (e.code !== "EEXIST" && e.code !== "EROFS") {
  257. throw e;
  258. }
  259. // EEXIST: throw if file and not directory
  260. // EROFS : throw if directory not found
  261. else {
  262. try {
  263. if (fs.statSync(dir).isDirectory()) {
  264. return dir;
  265. }
  266. throw e;
  267. } catch (err) {
  268. throw e;
  269. }
  270. }
  271. }
  272. };
  273. mkdir(this.fileObject.dir);
  274. const ops = {
  275. flags: this.options.flags,
  276. encoding: this.options.encoding,
  277. mode: this.options.mode
  278. };
  279. const renameKey = function(obj, oldKey, newKey) {
  280. obj[newKey] = obj[oldKey];
  281. delete obj[oldKey];
  282. return obj;
  283. };
  284. // try to throw EISDIR, EROFS, EACCES
  285. fs.appendFileSync(filePath, "", renameKey({ ...ops }, "flags", "flag"));
  286. this.currentFileStream = fs.createWriteStream(filePath, ops);
  287. this.currentFileStream.on("error", e => {
  288. this.emit("error", e);
  289. });
  290. }
  291. async _clean() {
  292. const existingFileDetails = await this._getExistingFiles();
  293. debug(
  294. `_clean: numToKeep = ${this.options.numToKeep}, existingFiles = ${existingFileDetails.length}`
  295. );
  296. debug("_clean: existing files are: ", existingFileDetails);
  297. if (this._tooManyFiles(existingFileDetails.length)) {
  298. const fileNamesToRemove = existingFileDetails
  299. .slice(0, existingFileDetails.length - this.options.numToKeep)
  300. .map(f => path.format({ dir: this.fileObject.dir, base: f.filename }));
  301. await deleteFiles(fileNamesToRemove);
  302. }
  303. }
  304. _tooManyFiles(numFiles) {
  305. return this.options.numToKeep > 0 && numFiles > this.options.numToKeep;
  306. }
  307. }
  308. module.exports = RollingFileWriteStream;