├── .codeclimate.yml ├── .eslintrc.cjs ├── .gitattributes ├── .nvmrc ├── .travis.yml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.ts ├── jest.config.cjs ├── package.json ├── test ├── 01rfs.test.ts ├── 02write.test.ts ├── 03size.test.ts ├── 04errors.test.ts ├── 05options.test.ts ├── 06interval.test.ts ├── 07compression.test.ts ├── 08classical.test.ts ├── 09history.test.ts ├── 99clean.test.ts └── helper.ts ├── testSequencer.cjs ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json ├── tsconfig.types.json ├── utils.ts └── yarn.lock /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | checks: 3 | file-lines: 4 | config: 5 | threshold: 1000 6 | method-complexity: 7 | config: 8 | threshold: 30 9 | method-count: 10 | config: 11 | threshold: 35 12 | method-lines: 13 | config: 14 | threshold: 100 15 | return-statements: 16 | config: 17 | threshold: 20 18 | similar-code: 19 | config: 20 | threshold: 60 21 | engines: 22 | duplication: 23 | enabled: true 24 | config: 25 | languages: 26 | - javascript 27 | - typescript 28 | eslint: 29 | enabled: true 30 | channel: eslint-6 31 | fixme: 32 | enabled: true 33 | ratings: 34 | paths: 35 | - "*.ts" 36 | - "test/*.ts" 37 | - "test/*.js" 38 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const rules = { 2 | "@typescript-eslint/no-empty-function": "off", 3 | "@typescript-eslint/no-empty-interface": "off", 4 | "@typescript-eslint/no-explicit-any": "off", 5 | "@typescript-eslint/no-unsafe-declaration-merging": "off", 6 | "@typescript-eslint/type-annotation-spacing": ["error", { after: true, before: false, overrides: { arrow: { before: true } } }], 7 | "arrow-body-style": ["error", "as-needed"], 8 | "arrow-parens": ["error", "as-needed"], 9 | "arrow-spacing": "error", 10 | "brace-style": ["error", "1tbs", { allowSingleLine: true }], 11 | curly: ["error", "multi-or-nest"], 12 | indent: ["error", 2], 13 | "key-spacing": ["error", { align: { afterColon: true, beforeColon: false, on: "value" } }], 14 | "keyword-spacing": ["error", { before: true, overrides: { catch: { after: false }, for: { after: false }, if: { after: false }, switch: { after: false }, while: { after: false } } }], 15 | "linebreak-style": ["warn", "unix"], 16 | "no-console": "warn", 17 | "no-tabs": "error", 18 | "nonblock-statement-body-position": ["error", "beside"], 19 | semi: ["error", "always"], 20 | "sort-keys": "off", 21 | "sort-keys/sort-keys-fix": "error", 22 | "space-before-function-paren": ["error", { anonymous: "never", asyncArrow: "always", named: "never" }], 23 | "space-unary-ops": ["error", { nonwords: false, overrides: { "!": true }, words: true }] 24 | }; 25 | 26 | module.exports = { 27 | env: { amd: true, browser: true, es6: true, jquery: true, node: true }, 28 | extends: ["plugin:@typescript-eslint/recommended"], 29 | parser: "@typescript-eslint/parser", 30 | parserOptions: { ecmaVersion: 9, sourceType: "module" }, 31 | plugins: ["sort-keys"], 32 | rules 33 | }; 34 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | .* text eol=lf 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.10.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | after_script: 2 | - "if [[ `node --version` =~ ^v20 ]] ; then ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT ; fi" 3 | 4 | before_script: 5 | - "curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter" 6 | - "chmod +x ./cc-test-reporter" 7 | - "./cc-test-reporter before-build" 8 | 9 | dist: focal 10 | 11 | env: 12 | global: 13 | - CC_TEST_REPORTER_ID=b96c8e14d13e6e0eac109776042700ebba5cddd019d9031fc476d8110842920f 14 | 15 | node_js: 16 | - "20" 17 | - "18" 18 | 19 | language: node_js 20 | script: "yarn coverage" 21 | sudo: false 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "myh.preview-vscode", "rohit-gohri.format-code-action", "streetsidesoftware.code-spell-checker"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Jest Tests", 8 | "program": "${workspaceFolder}/node_modules/.bin/jest", 9 | "args": ["-i"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "allevo", 4 | "bernack", 5 | "cchare", 6 | "cicci", 7 | "daniele", 8 | "filebeat", 9 | "immutate", 10 | "instanceof", 11 | "jvassev", 12 | "kbirger", 13 | "lcov", 14 | "linebreak", 15 | "nonblock", 16 | "nonwords", 17 | "npmignore", 18 | "parens", 19 | "pyues", 20 | "rakshith", 21 | "ravi", 22 | "reclose", 23 | "refinal", 24 | "ricci", 25 | "satoshis", 26 | "wangao", 27 | "zmssi" 28 | ], 29 | "editor.codeActionsOnSave": ["source.formatDocument", "source.fixAll.eslint"], 30 | "editor.defaultFormatter": "esbenp.prettier-vscode", 31 | "editor.formatOnSave": true, 32 | "eslint.validate": ["javascript", "typescript"], 33 | "explorer.confirmDelete": false, 34 | "files.autoSave": "off", 35 | "files.eol": "\n", 36 | "files.insertFinalNewline": true, 37 | "files.trimFinalNewlines": true, 38 | "git.ignoreMissingGitWarning": true, 39 | "window.zoomLevel": 0, 40 | "[javascript]": { "editor.formatOnSave": false }, 41 | "[typescript]": { "editor.formatOnSave": false } 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | - 2025-02-10 - v3.2.6 2 | - Fixed a bug [when using explicit false value for compress option](https://github.com/iccicci/rotating-file-stream/issues/110) (thanks to [eranbetzalel](https://github.com/eranbetzalel)) 3 | - 2024-09-16 - v3.2.5 4 | - Use `mtime` rather than `ctime` to sort files history. 5 | - 2024-09-13 - v3.2.4 6 | - Fixed a bug [with electron](https://github.com/iccicci/rotating-file-stream/issues/106) (thanks to [mrawji](https://github.com/mrawji)) 7 | - 2024-06-10 - v3.2.3 8 | - Fixed ESM dist. 9 | - 2024-06-08 - v3.2.2 10 | - Fixed ESM dist. 11 | - 2023-12-28 - v3.2.1 12 | - Restored support for **Node.js v14**. 13 | - Badges (but dependents) from [shields.io](https://shields.io/). 14 | - 2023-12-27 - v3.2.0 15 | - Added `intervalUTC` as [requested](https://github.com/iccicci/rotating-file-stream/issues/97). 16 | - 2023-07-22 - v3.1.1 17 | - Fixed a bug which [causes occasional crashes on rotation by timer](https://github.com/iccicci/rotating-file-stream/issues/84) (thanks to [diwic](https://github.com/diwic)) 18 | - 2023-02-09 - v3.1.0 19 | - `ENOENT` errors while unlinking files now emits `"warning"` events rather than `"error"`. 20 | - 2022-05-25 - v3.0.4 21 | - Fixed required Node.js version in README.md 22 | - 2022-02-22 - v3.0.3 23 | - Experimental ESM dist 24 | - 2021-11-20 - v3.0.2 25 | - Required **Node.js v14** 26 | - 2021-11-06 - v3.0.1 27 | - `external` event emitted even if in error 28 | - exported `class RotatingFileStreamError` 29 | - Minor [README.md](https://github.com/iccicci/rotating-file-stream/blob/master/README.md) fixes 30 | - 2021-11-04 - v3.0.0 31 | - Released v3 - please check the [README.md](https://www.npmjs.com/package/rotating-file-stream#upgrading-from-v2-to-v3) 32 | - 2021-09-26 - v2.1.6 33 | - Made the [package compliant with Node.js v14 and v16](https://github.com/iccicci/rotating-file-stream/issues/63) 34 | - Removed duplicated description for initialRotation option (thanks to [ttoomm318](https://github.com/ttoomm318)) 35 | - 2021-02-19 - v2.1.5 36 | - Changed \_writev to make it compliant with Node.js v14; this should solve [#67](https://github.com/iccicci/rotating-file-stream/issues/67) 37 | - 2021-02-02 - v2.1.4 38 | - Fixed a bug which [requires a write operation to emit errors on open](https://github.com/iccicci/rotating-file-stream/issues/62) (thanks to [Raynos](https://github.com/Raynos)) 39 | - 2020-07-07 - v2.1.3 40 | - Fixed a bug causing EEXIST opening two files in same not existing directory in a rapid sequence (thanks to [jameslahm](https://github.com/jameslahm)) 41 | - 2020-07-04 - v2.1.2 42 | - Applied a change to use on mounts with the noexec option (thanks to [jvassev](https://github.com/jvassev)) 43 | - 2020-05-13 - v2.1.1 44 | - Bug fix for **teeToStdout** 45 | - 2020-04-29 - v2.1.0 46 | - **teeToStdout** option added 47 | - 2020-01-26 - v2.0.2 48 | - Fixed two [bugs](https://github.com/iccicci/rotating-file-stream/pull/47) 49 | - 2019-12-24 - v2.0.1 50 | - Fixed a [bug about setTimeout on some node environments](https://github.com/iccicci/rotating-file-stream/pull/46) (thanks to [Rastopyr](https://github.com/Rastopyr)) 51 | - 2019-11-24 - v2.0.0 52 | - complete refactoring with TypeScript 53 | - full Windows compliance (at least all tests are OK) 54 | - file is recreated if externally removed while logging 55 | - 2019-10-20 - v1.4.6 56 | - tests fix 57 | - 2019-10-18 - v1.4.5 58 | - Fixed a [bug](https://github.com/iccicci/rotating-file-stream/issues/39) when **immutable** and **history** are both set. 59 | - 2019-10-01 - v1.4.4 60 | - Fixed a [bug](https://github.com/iccicci/rotating-file-stream/issues/42) occurring adding properties to **Array.prototype** 61 | - 2019-07-23 - v1.4.3 62 | - Exported the options interface 63 | - 2019-06-27 - v1.4.2 64 | - Fixed a [bug causing a ERR_MULTIPLE_CALLBACK error](https://github.com/iccicci/rotating-file-stream/issues/36) (thanks to [rooftopsparrow](https://github.com/rooftopsparrow)) 65 | - 2019-04-22 - v1.4.1 66 | - From [istanbul](https://www.npmjs.com/package/istanbul) to [nyc](https://www.npmjs.com/package/nyc) for tests coverage 67 | - Several typos fixed in [README.md](https://github.com/iccicci/rotating-file-stream/blob/master/README.md); thanks to [dhurlburtusa](https://github.com/dhurlburtusa) 68 | - 2019-01-09 - v1.4.0 69 | - Fixed the [TimeoutOverflowWarning bug](https://github.com/iccicci/rotating-file-stream/issues/34) 70 | - Added **experimental** monthly rotation 71 | - 2019-01-04 - v1.3.10 72 | - Fixed a [bug occurring when two calls to _makePath_ are concurrently done](https://github.com/iccicci/rotating-file-stream/pull/33) (thanks to [cchare](https://github.com/cchare)) 73 | - 2018-09-26 - v1.3.9 74 | - Fixed TypeScript Definition file (thanks to [rakshith-ravi](https://www.npmjs.com/~rakshith-ravi) and [kbirger](https://www.npmjs.com/~kbirger)) 75 | - Added TOC and **TypeScript** import documentation 76 | - 2018-09-18 - v1.3.8 77 | - Added TypeScript Definition file (thanks to [rakshith-ravi](https://www.npmjs.com/~rakshith-ravi)) 78 | - 2018-07-19 - v1.3.7 79 | - Discovered and solved: ["write after end" error with immutable option](https://github.com/iccicci/rotating-file-stream/issues/23) (thanks to [JcBernack](https://github.com/JcBernack)) 80 | - Added a test case to cover that bug 81 | - 2018-03-15 - v1.3.5 82 | - Using slightly faster timestamp generator function (thanks to [jorgemsrs](https://github.com/jorgemsrs)) 83 | - 2017-11-13 - v1.3.4 84 | - **immutable** option review 85 | - 2017-11-13 - v1.3.3 86 | - Solved: [problem with TypeScript](https://github.com/iccicci/rotating-file-stream/issues/19) 87 | - **immutable** option added 88 | - 2017-09-17 - v1.3.0 89 | - **initialRotation** option added 90 | - 2017-04-26 - v1.2.2 91 | - Fixed bug: [Handle does not close](https://github.com/iccicci/rotating-file-stream/issues/11) 92 | - 2017-03-22 - v1.2.1 93 | - fixed removed event 94 | - 2017-03-20 - v1.2.0 95 | - **maxFiles** and **maxSize** options added 96 | - 2017-02-14 - v1.1.9 97 | - fixed warning events order in case of external compression errors 98 | - 2017-02-13 - v1.1.8 99 | - removed tmp dependecy due it was causing a strange instability now disappeared 100 | - 2017-02-07 - v1.1.7 101 | - fixed tmp.file call 102 | - 2017-02-03 - v1.1.6 103 | - eslint 104 | - 2017-01-23 - v1.1.5 105 | - README fix 106 | - 2017-01-23 - v1.1.4 107 | - Changed dependencies badges 108 | - 2016-12-27 - v1.1.3 109 | - Fixed bug: [end method wrong implementation](https://github.com/iccicci/rotating-file-stream/issues/9) 110 | - 2016-12-19 - v1.1.2 111 | - Fixed bug: [unable to reuse configuration object](https://github.com/iccicci/rotating-file-stream/issues/10) 112 | - Fixed bug: [Events cross over: rotate and rotated](https://github.com/iccicci/rotating-file-stream/issues/6) 113 | - 2016-12-05 - v1.1.1 114 | - Added classical **UNIX logrotate** tool behaviour. 115 | - 2016-04-29 - v1.0.5 116 | - Tested on node v6.0 117 | - Fixed a bug on rotation with interval and compression 118 | - 2015-11-09 - v1.0.4 119 | - Tested on node v5.0 120 | - Fixed bug on [initial rotation with interval](https://github.com/iccicci/rotating-file-stream/issues/2) 121 | - 2015-10-25 - v1.0.3 122 | - Tested on node v4.2 123 | - 2015-10-09 - v1.0.2 124 | - README update 125 | - 2015-10-08 - v1.0.1 126 | - README fix 127 | - 2015-10-08 - v1.0.0 128 | - Async error reporting refactory 129 | - 2015-10-07 - v0.1.0 130 | - Internal gzip compression 131 | - 2015-10-06 - v0.0.5 132 | - External compression 133 | - 2015-09-30 - v0.0.4 134 | - Added _path_ option 135 | - Missing path creation 136 | - 2015-09-29 - v0.0.3 137 | - Rotation by interval 138 | - **Buffer** optimization (thanks to [allevo](https://www.npmjs.com/~allevo)) 139 | - 2015-09-17 - v0.0.2 140 | - Rotation by size 141 | - 2015-09-14 - v0.0.1 142 | - README.md 143 | - 2015-09-10 - v0.0.0 144 | - Embryonal stage 145 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Daniele Ricci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rotating-file-stream 2 | 3 | [![Build Status][travis-badge]][travis-url] 4 | [![Code Climate][code-badge]][code-url] 5 | [![Test Coverage][cover-badge]][code-url] 6 | 7 | [![NPM version][npm-badge]][npm-url] 8 | [![NPM downloads][npm-downloads-badge]][npm-url] 9 | [![Stars][stars-badge]][stars-url] 10 | 11 | [![Types][types-badge]][npm-url] 12 | [![Dependents][deps-badge]][npm-url] 13 | [![Donate][donate-badge]][donate-url] 14 | 15 | [code-badge]: https://codeclimate.com/github/iccicci/rotating-file-stream/badges/gpa.svg 16 | [code-url]: https://codeclimate.com/github/iccicci/rotating-file-stream 17 | [cover-badge]: https://codeclimate.com/github/iccicci/rotating-file-stream/badges/coverage.svg 18 | [deps-badge]: https://img.shields.io/librariesio/dependents/npm/rotating-file-stream?logo=npm 19 | [deps-url]: https://www.npmjs.com/package/rotating-file-stream?activeTab=dependents 20 | [donate-badge]: https://img.shields.io/static/v1?label=donate&message=bitcoin&color=blue&logo=bitcoin 21 | [donate-url]: https://blockchain.info/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN 22 | [github-url]: https://github.com/iccicci/rotating-file-stream 23 | [npm-downloads-badge]: https://img.shields.io/npm/dw/rotating-file-stream?logo=npm 24 | [npm-badge]: https://img.shields.io/npm/v/rotating-file-stream?color=green&logo=npm 25 | [npm-url]: https://www.npmjs.com/package/rotating-file-stream 26 | [stars-badge]: https://img.shields.io/github/stars/iccicci/rotating-file-stream?logo=github&style=flat&color=green 27 | [stars-url]: https://github.com/iccicci/rotating-file-stream/stargazers 28 | [travis-badge]: https://img.shields.io/travis/com/iccicci/rotating-file-stream?logo=travis 29 | [travis-url]: https://app.travis-ci.com/github/iccicci/rotating-file-stream 30 | [types-badge]: https://img.shields.io/static/v1?label=types&message=included&color=green&logo=typescript 31 | 32 | ### Description 33 | 34 | Creates a [stream.Writable](https://nodejs.org/api/stream.html#stream_class_stream_writable) to a file which is 35 | rotated. Rotation behavior can be deeply customized; optionally, classical UNIX **logrotate** behavior can be used. 36 | 37 | ### Usage 38 | 39 | ```javascript 40 | const rfs = require("rotating-file-stream"); 41 | const stream = rfs.createStream("file.log", { 42 | size: "10M", // rotate every 10 MegaBytes written 43 | interval: "1d", // rotate daily 44 | compress: "gzip" // compress rotated files 45 | }); 46 | ``` 47 | 48 | ### Installation 49 | 50 | With [npm](https://www.npmjs.com/package/rotating-file-stream): 51 | 52 | ```sh 53 | $ npm install --save rotating-file-stream 54 | ``` 55 | 56 | ### Table of contents 57 | 58 | - [Upgrading from v2 to v3](#upgrading-from-v2-to-v3) 59 | - [Upgrading from v1 to v2](#upgrading-from-v1-to-v2) 60 | - [API](#api) 61 | - [rfs.createStream(filename[, options])](#rfscreatestreamfilename-options) 62 | - [filename](#filename) 63 | - [filename(time[, index])](#filenametime-index) 64 | - [filename(index)](#filenameindex) 65 | - [Class: RotatingFileStream](#class-rotatingfilestream) 66 | - [Event: 'external'](#event-external) 67 | - [Event: 'history'](#event-history) 68 | - [Event: 'open'](#event-open) 69 | - [Event: 'removed'](#event-removed) 70 | - [Event: 'rotation'](#event-rotation) 71 | - [Event: 'rotated'](#event-rotated) 72 | - [Event: 'warning'](#event-warning) 73 | - [options](#options) 74 | - [compress](#compress) 75 | - [encoding](#encoding) 76 | - [history](#history) 77 | - [immutable](#immutable) 78 | - [initialRotation](#initialrotation) 79 | - [interval](#interval) 80 | - [intervalBoundary](#intervalboundary) 81 | - [intervalUTC](#intervalutc) 82 | - [maxFiles](#maxfiles) 83 | - [maxSize](#maxsize) 84 | - [mode](#mode) 85 | - [omitExtension](#omitextension) 86 | - [path](#path) 87 | - [rotate](#rotate) 88 | - [size](#size) 89 | - [teeToStdout](#teeToStdout) 90 | - [Rotation logic](#rotation-logic) 91 | - [Under the hood](#under-the-hood) 92 | - [Compatibility](#compatibility) 93 | - [TypeScript](#typescript) 94 | - [License](#license) 95 | - [Bugs](#bugs) 96 | - [ChangeLog](#changelog) 97 | - [Donating](#donating) 98 | 99 | # Upgrading from v2 to v3 100 | 101 | In **v3** the package was completely refactored using **async / await**. 102 | 103 | **TypeScript** types for events and the [external](#event-external) event were added. 104 | 105 | **Breaking change**: by default the `.gz` extension is added to the rotated compressed files. 106 | 107 | **Breaking change**: the way the _external compression command_ is executed was slightly changed; possible breaking 108 | change. 109 | 110 | To maintain back compatibility upgrading from **v2** to **v3**, just follow this rules: 111 | 112 | - using a _file name generator_ or not using [`options.compress`](#compress): nothing to do 113 | - using a _file name_ and using [`options.compress`](#compress): use [`options.omitExtension`](#omitextension) or check 114 | how rotated files are treated. 115 | 116 | # Upgrading from v1 to v2 117 | 118 | There are two main changes in package interface. 119 | 120 | In **v1** the _default export_ of the package was directly the **RotatingFileStream** _constructor_ and the caller 121 | have to use it; while in **v2** there is no _default export_ and the caller should use the 122 | [createStream](#rfscreatestreamfilename-options) exported function and should not directly use 123 | [RotatingFileStream](#class-rotatingfilestream) class. 124 | This is quite easy to discover: if this change is not applied, nothing than a runtime error can happen. 125 | 126 | The other important change is the removal of option **rotationTime** and the introduction of **intervalBoundary**. 127 | In **v1** the `time` argument passed to the _filename generator_ function, by default, is the time when _rotation job_ 128 | started, while if [`options.interval`](#interval) option is used, it is the lower boundary of the time interval within 129 | _rotation job_ started. Later I was asked to add the possibility to restore the default value for this argument so I 130 | introduced `options.rotationTime` option with this purpose. At the end the result was something a bit confusing, 131 | something I never liked. 132 | In **v2** the `time` argument passed to the _filename generator_ function is always the time when _rotation job_ 133 | started, unless [`options.intervalBoundary`](#intervalboundary) option is used. In a few words, to maintain back 134 | compatibility upgrading from **v1** to **v2**, just follow this rules: 135 | 136 | - using [`options.rotation`](#rotation): nothing to do 137 | - not using [`options.rotation`](#rotation): 138 | - not using [`options.interval`](#interval): nothing to do 139 | - using [`options.interval`](#interval): 140 | - using `options.rotationTime`: to remove it 141 | - not using `options.rotationTime`: then use [`options.intervalBoundary`](#intervalboundary). 142 | 143 | # API 144 | 145 | ```javascript 146 | const rfs = require("rotating-file-stream"); 147 | ``` 148 | 149 | ## rfs.createStream(filename[, options]) 150 | 151 | - `filename` [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) | 152 | [<Function>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) The name 153 | of the file or the function to generate it, called _file name generator_. See below for 154 | [details](#filename-stringfunction). 155 | - `options` [<Object>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object) 156 | Rotation options, See below for [details](#options). 157 | - Returns: [<RotatingFileStream>](#class-rotatingfilestream) The **rotating file stream**! 158 | 159 | This interface is inspired to 160 | [fs.createWriteStream](https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options) one. The file is rotated 161 | following _options_ rules. 162 | 163 | ### filename 164 | 165 | The most complex problem about file name is: _how to call the rotated file name?_ 166 | 167 | The answer to this question may vary in many forms depending on application requirements and/or specifications. 168 | If there are no requirements, a `string` can be used and _default rotated file name generator_ will be used; 169 | otherwise a `Function` which returns the _rotated file name_ can be used. 170 | 171 | **Note:** 172 | if part of returned destination path does not exists, the rotation job will try to create it. 173 | 174 | #### filename(time[, index]) 175 | 176 | - `time` [<Date>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) 177 | 178 | - By default: the time when rotation job started; 179 | - if both [`options.interval`](#interval) and [`intervalBoundary`](#intervalboundary) options are enabled: the start 180 | time of rotation period. 181 | 182 | If `null`, the _not-rotated file name_ must be returned. 183 | 184 | - `index` [<number>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) The 185 | progressive index of rotation by size in the same rotation period. 186 | 187 | An example of a complex _rotated file name generator_ function could be: 188 | 189 | ```javascript 190 | const pad = num => (num > 9 ? "" : "0") + num; 191 | const generator = (time, index) => { 192 | if (!time) return "file.log"; 193 | 194 | var month = time.getFullYear() + "" + pad(time.getMonth() + 1); 195 | var day = pad(time.getDate()); 196 | var hour = pad(time.getHours()); 197 | var minute = pad(time.getMinutes()); 198 | 199 | return `${month}/${month}${day}-${hour}${minute}-${index}-file.log`; 200 | }; 201 | 202 | const rfs = require("rotating-file-stream"); 203 | const stream = rfs.createStream(generator, { 204 | size: "10M", 205 | interval: "30m" 206 | }); 207 | ``` 208 | 209 | **Note:** 210 | if both of [`options.interval`](#interval) [`options.size`](#size) are used, returned _rotated file name_ **must** be 211 | function of both arguments `time` and `index`. 212 | 213 | #### filename(index) 214 | 215 | - `index` [<number>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) The 216 | progressive index of rotation. If `null`, the _not-rotated file name_ must be returned. 217 | 218 | If classical **logrotate** behavior is enabled (by [`options.rotate`](#rotate)), _rotated file name_ is only a 219 | function of `index`. 220 | 221 | ## Class: RotatingFileStream 222 | 223 | Extends [stream.Writable](https://nodejs.org/api/stream.html#stream_class_stream_writable). It should not be directly 224 | used. Exported only to be used with `instanceof` operator and similar. 225 | 226 | ### Event: 'external' 227 | 228 | - `stdout` [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The 229 | standard output of the external compression command. 230 | - `stderr` [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The 231 | standard error of the external compression command. 232 | 233 | The `external` event is emitted once an _external compression command_ completes its execution to give access to the 234 | command output streams. 235 | 236 | ### Event: 'history' 237 | 238 | The `history` event is emitted once the _history check job_ is completed. 239 | 240 | ### Event: 'open' 241 | 242 | - `filename` [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) Is 243 | constant unless [`options.immutable`](#immutable) is `true`. 244 | 245 | The `open` event is emitted once the _not-rotated file_ is opened. 246 | 247 | ### Event: 'removed' 248 | 249 | - `filename` [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The 250 | name of the removed file. 251 | - `number` [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 252 | - `true` if the file was removed due to [`options.maxFiles`](#maxFiles) 253 | - `false` if the file was removed due to [`options.maxSize`](#maxSize) 254 | 255 | The `removed` event is emitted once a _rotated file_ is removed due to [`options.maxFiles`](#maxFiles) or 256 | [`options.maxSize`](#maxSize). 257 | 258 | ### Event: 'rotation' 259 | 260 | The `rotation` event is emitted once the _rotation job_ is started. 261 | 262 | ### Event: 'rotated' 263 | 264 | - `filename` [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The 265 | _rotated file name_ produced. 266 | 267 | The `rotated` event is emitted once the _rotation job_ is completed. 268 | 269 | ### Event: 'warning' 270 | 271 | - `error` [<Error>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) The 272 | non blocking error. 273 | 274 | The `warning` event is emitted once a non blocking error happens. 275 | 276 | ## options 277 | 278 | - [`compress`](#compress): 279 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) | 280 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) | 281 | [<Function>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) 282 | Specifies compression method of rotated files. **Default:** `null`. 283 | - [`encoding`](#encoding): 284 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) 285 | Specifies the default encoding. **Default:** `'utf8'`. 286 | - [`history`](#history): 287 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) 288 | Specifies the _history filename_. **Default:** `null`. 289 | - [`immutable`](#immutable): 290 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 291 | Never mutate file names. **Default:** `null`. 292 | - [`initialRotation`](#initialRotation): 293 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 294 | Initial rotation based on _not-rotated file_ timestamp. **Default:** `null`. 295 | - [`interval`](#interval): 296 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) 297 | Specifies the time interval to rotate the file. **Default:** `null`. 298 | - [`intervalBoundary`](#intervalBoundary): 299 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 300 | Makes rotated file name with lower boundary of rotation period. **Default:** `null`. 301 | - [`intervalUTC`](#intervalutc): 302 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 303 | Boundaries for rotation are computed in UTC. **Default:** `null`. 304 | - [`maxFiles`](#maxFiles): 305 | [<number>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) 306 | Specifies the maximum number of rotated files to keep. **Default:** `null`. 307 | - [`maxSize`](#maxSize): 308 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) 309 | Specifies the maximum size of rotated files to keep. **Default:** `null`. 310 | - [`mode`](#mode): 311 | [<number>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) 312 | Forwarded to [fs.createWriteStream](https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options). 313 | **Default:** `0o666`. 314 | - [`omitExtension`](#omitextension): 315 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 316 | Omits the `.gz` extension from compressed rotated files. **Default:** `null`. 317 | - [`path`](#path): 318 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) 319 | Specifies the base path for files. **Default:** `null`. 320 | - [`rotate`](#rotate): 321 | [<number>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) 322 | Enables the classical UNIX **logrotate** behavior. **Default:** `null`. 323 | - [`size`](#size): 324 | [<string>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) 325 | Specifies the file size to rotate the file. **Default:** `null`. 326 | - [`teeToStdout`](#teeToStdout): 327 | [<boolean>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type) 328 | Writes file content to `stdout` as well. **Default:** `null`. 329 | 330 | ### encoding 331 | 332 | Specifies the default encoding that is used when no encoding is specified as an argument to 333 | [stream.write()](https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback). 334 | 335 | ### mode 336 | 337 | Forwarded to [fs.createWriteStream](https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options). 338 | 339 | ### path 340 | 341 | If present, it is prepended to generated file names as well as for history file. 342 | 343 | ### teeToStdout 344 | 345 | If `true`, it makes the file content to be written to `stdout` as well. Useful for debugging purposes. 346 | 347 | ### size 348 | 349 | Accepts a positive integer followed by one of these possible letters: 350 | 351 | - **B**: Bites 352 | - **K**: KiloBites 353 | - **M**: MegaBytes 354 | - **G**: GigaBytes 355 | 356 | ```javascript 357 | size: '300B', // rotates the file when size exceeds 300 Bytes 358 | // useful for tests 359 | ``` 360 | 361 | ```javascript 362 | size: '300K', // rotates the file when size exceeds 300 KiloBytes 363 | ``` 364 | 365 | ```javascript 366 | size: '100M', // rotates the file when size exceeds 100 MegaBytes 367 | ``` 368 | 369 | ```javascript 370 | size: '1G', // rotates the file when size exceeds a GigaByte 371 | ``` 372 | 373 | ### interval 374 | 375 | Accepts a positive integer followed by one of these possible letters: 376 | 377 | - **s**: seconds. Accepts integer divider of 60. 378 | - **m**: minutes. Accepts integer divider of 60. 379 | - **h**: hours. Accepts integer divider of 24. 380 | - **d**: days. Accepts integer. 381 | - **M**: months. Accepts integer. **EXPERIMENTAL** 382 | 383 | ```javascript 384 | interval: '5s', // rotates at seconds 0, 5, 10, 15 and so on 385 | // useful for tests 386 | ``` 387 | 388 | ```javascript 389 | interval: '5m', // rotates at minutes 0, 5, 10, 15 and so on 390 | ``` 391 | 392 | ```javascript 393 | interval: '2h', // rotates at midnight, 02:00, 04:00 and so on 394 | ``` 395 | 396 | ```javascript 397 | interval: '1d', // rotates at every midnight 398 | ``` 399 | 400 | ```javascript 401 | interval: '1M', // rotates at every midnight between two distinct months 402 | ``` 403 | 404 | ### intervalBoundary 405 | 406 | If set to `true`, the argument `time` of _filename generator_ is no longer the time when _rotation job_ started, but 407 | the _lower boundary_ of rotation interval. 408 | 409 | **Note:** 410 | this option has effect only if [`options.interval`](#interval) is used. 411 | 412 | ### intervalUTC 413 | 414 | If set to `true`, the boundaries of the rotation interval are computed against UTC time rather than against system time 415 | zone. 416 | 417 | **Note:** 418 | this option has effect only if [`options.intervalBoundary`](#intervalboundary) is used. 419 | 420 | ### compress 421 | 422 | The best choice here is to use the value `"gzip"` to use **Node.js** internal compression library. 423 | 424 | For historical reasons external compression can be used. 425 | 426 | To enable external compression, a _function_ can be used or simply the _boolean_ `true` value to use default 427 | external compression. 428 | The function should accept `source` and `dest` file names and must return the shell command to be executed to 429 | compress the file. 430 | The two following code snippets have exactly the same effect: 431 | 432 | ```javascript 433 | var rfs = require("rotating-file-stream"); 434 | var stream = rfs.createStream("file.log", { 435 | size: "10M", 436 | compress: true 437 | }); 438 | ``` 439 | 440 | ```javascript 441 | var rfs = require("rotating-file-stream"); 442 | var stream = rfs.createStream("file.log", { 443 | size: "10M", 444 | compress: (source, dest) => `cat ${source} | gzip -c9 > ${dest}` 445 | }); 446 | ``` 447 | 448 | **Note:** 449 | this option is ignored if [`options.immutable`](#immutable) is used. 450 | 451 | **Note:** 452 | the shell command to compress the rotated file should not remove the source file, it will be removed by the package 453 | if rotation job complete with success. 454 | 455 | ### omitExtension 456 | 457 | From **v3** the package adds by default the `.gz` extension to the rotated compressed files. Simultaneously this option 458 | was added: set this option to `true` to not add the extension, i.e. to keep backward compatibility. 459 | 460 | ### initialRotation 461 | 462 | When program stops in a rotation period then restarts in a new rotation period, logs of different rotation period will 463 | go in the next rotated file; in a few words: a rotation job is lost. If this option is set to `true` an initial check 464 | is performed against the _not-rotated file_ timestamp and, if it falls in a previous rotation period, an initial 465 | rotation job is done as well. 466 | 467 | **Note:** 468 | this option has effect only if both [`options.interval`](#interval) and [`options.intervalBoundary`](#intervalboundary) 469 | are used. 470 | 471 | **Note:** 472 | this option is ignored if [`options.rotate`](#rotate) is used. 473 | 474 | ### rotate 475 | 476 | If specified, classical UNIX **logrotate** behavior is enabled and the value of this option has same effect in 477 | _logrotate.conf_ file. 478 | 479 | **Note:** 480 | if this option is used following ones take no effect: [`options.history`](#history), [`options.immutable`](#immutable), 481 | [`options.initialRotation`](#initialrotation), [`options.intervalBoundary`](#intervalboundary), 482 | [`options.maxFiles`](#maxfiles), [`options.maxSize`](#maxsize). 483 | 484 | ### immutable 485 | 486 | If set to `true`, names of generated files never changes. New files are immediately generated with their rotated 487 | name. In other words the _rotated file name generator_ is never called with a `null` _time_ argument unless to 488 | determinate the _history file_ name; this can happen if [`options.history`](#history) is not used while 489 | [`options.maxFiles`](#maxfiles) or [`options.maxSize`](#maxsize) are used. 490 | The `filename` argument passed to [`'open'`](#event-open) _event_ evaluates now as the newly created file name. 491 | 492 | Useful to send logs to logstash through [filebeat](https://www.elastic.co/beats/filebeat). 493 | 494 | **Note:** 495 | if this option is used, [`options.compress`](#compress) is ignored. 496 | 497 | **Note:** 498 | this option is ignored if [`options.interval`](#interval) is not used. 499 | 500 | ### history 501 | 502 | Due to the complexity that _rotated file names_ can have because of the _filename generator function_, if number or 503 | size of rotated files should not exceed a given limit, the package needs a file where to store this information. This 504 | option specifies the name _history file_. This option takes effect only if at least one of 505 | [`options.maxFiles`](#maxfiles) or [`options.maxSize`](#maxsize) is used. If `null`, the _not rotated filename_ with 506 | the `'.txt'` suffix is used. 507 | 508 | ### maxFiles 509 | 510 | If specified, it's value is the maximum number of _rotated files_ to be kept. 511 | 512 | ### maxSize 513 | 514 | If specified, it's value must respect same syntax of [option.size](#size) and is the maximum size of _rotated files_ to 515 | be kept. 516 | 517 | # Rotation logic 518 | 519 | Regardless of when and why rotation happens, the content of a single 520 | [stream.write](https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback) 521 | will never be split among two files. 522 | 523 | ## by size 524 | 525 | Once the _not-rotated_ file is opened first time, its size is checked and if it is greater or equal to 526 | size limit, a first rotation happens. After each 527 | [stream.write](https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback), 528 | the same check is performed. 529 | 530 | ## by interval 531 | 532 | The package sets a [Timeout](https://nodejs.org/api/timers.html#timers_settimeout_callback_delay_args) 533 | to start a rotation job at the right moment. 534 | 535 | # Under the hood 536 | 537 | Logs should be handled so carefully, so this package tries to never overwrite files. 538 | 539 | At stream creation, if the _not-rotated_ log file already exists and its size exceeds the rotation size, 540 | an initial rotation attempt is done. 541 | 542 | At each rotation attempt a check is done to verify that destination rotated file does not exists yet; 543 | if this is not the case a new destination _rotated file name_ is generated and the same check is 544 | performed before going on. This is repeated until a not existing destination file name is found or the 545 | package is exhausted. For this reason the _rotated file name generator_ function could be called several 546 | times for each rotation job. 547 | 548 | If requested through [`options.maxFiles`](#maxfiles) or [`options.maxSize`](#maxsize), at the end of a rotation job, a 549 | check is performed to ensure that given limits are respected. This means that 550 | **while rotation job is running both the limits could be not respected**. The same can happen till the end of first 551 | _rotation job_ if [`options.maxFiles`](#maxfiles) or [`options.maxSize`](#maxsize) are changed between two runs. 552 | The first check performed is the one against [`options.maxFiles`](#maxfiles), in case some files are removed, then the 553 | check against [`options.maxSize`](#maxsize) is performed, finally other files can be removed. When 554 | [`options.maxFiles`](#maxfiles) or [`options.maxSize`](#maxsize) are enabled for first time, an _history file_ can be 555 | created with one _rotated filename_ (as returned by _filename generator function_) at each line. 556 | 557 | Once an **error** _event_ is emitted, nothing more can be done: the stream is closed as well. 558 | 559 | # Compatibility 560 | 561 | Requires **Node.js v14**. 562 | 563 | The package is tested under [all Node.js versions](https://app.travis-ci.com/github/iccicci/rotating-file-stream) 564 | currently supported accordingly to [Node.js Release](https://github.com/nodejs/Release#readme). 565 | 566 | To work with the package under Windows, be sure to configure `bash.exe` as your _script-shell_. 567 | 568 | ``` 569 | > npm config set script-shell bash.exe 570 | ``` 571 | 572 | # TypeScript 573 | 574 | **TypeScript** types are distributed with the package itself. 575 | 576 | # License 577 | 578 | [MIT License](https://github.com/iccicci/rotating-file-stream/blob/master/LICENSE) 579 | 580 | # Bugs 581 | 582 | Do not hesitate to report any bug or inconsistency [@github](https://github.com/iccicci/rotating-file-stream/issues). 583 | 584 | # ChangeLog 585 | 586 | [ChangeLog](https://github.com/iccicci/rotating-file-stream/blob/master/CHANGELOG.md) 587 | 588 | # Donating 589 | 590 | If you find useful this package, please consider the opportunity to donate some satoshis to this bitcoin address: 591 | **12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN** 592 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { Gzip, createGzip } from "zlib"; 3 | import { Readable, Writable } from "stream"; 4 | import { Stats, access, constants, createReadStream, createWriteStream } from "fs"; 5 | import { FileHandle, mkdir, open, readFile, rename, stat, unlink, writeFile } from "fs/promises"; 6 | import { sep } from "path"; 7 | import { TextDecoder } from "util"; 8 | 9 | // Do not remove: https://github.com/iccicci/rotating-file-stream/issues/106 10 | import { setTimeout } from "timers"; 11 | 12 | async function exists(filename: string): Promise { 13 | return new Promise(resolve => access(filename, constants.F_OK, error => resolve(! error))); 14 | } 15 | 16 | export class RotatingFileStreamError extends Error { 17 | public code = "RFS-TOO-MANY"; 18 | 19 | constructor() { 20 | super("Too many destination file attempts"); 21 | } 22 | } 23 | 24 | export type Compressor = (source: string, dest: string) => string; 25 | export type Generator = (time: number | Date, index?: number) => string; 26 | 27 | interface RotatingFileStreamEvents { 28 | // Inherited from Writable 29 | close: () => void; 30 | drain: () => void; 31 | error: (err: Error) => void; 32 | finish: () => void; 33 | pipe: (src: Readable) => void; 34 | unpipe: (src: Readable) => void; 35 | 36 | // RotatingFileStream defined 37 | external: (stdout: string, stderr: string) => void; 38 | history: () => void; 39 | open: (filename: string) => void; 40 | removed: (filename: string, number: boolean) => void; 41 | rotation: () => void; 42 | rotated: (filename: string) => void; 43 | warning: (error: Error) => void; 44 | } 45 | 46 | export declare interface RotatingFileStream extends Writable { 47 | addListener(event: Event, listener: RotatingFileStreamEvents[Event]): this; 48 | emit(event: Event, ...args: Parameters): boolean; 49 | on(event: Event, listener: RotatingFileStreamEvents[Event]): this; 50 | once(event: Event, listener: RotatingFileStreamEvents[Event]): this; 51 | prependListener(event: Event, listener: RotatingFileStreamEvents[Event]): this; 52 | prependOnceListener(event: Event, listener: RotatingFileStreamEvents[Event]): this; 53 | removeListener(event: Event, listener: RotatingFileStreamEvents[Event]): this; 54 | } 55 | 56 | export interface Options { 57 | compress?: boolean | string | Compressor; 58 | encoding?: BufferEncoding; 59 | history?: string; 60 | immutable?: boolean; 61 | initialRotation?: boolean; 62 | interval?: string; 63 | intervalBoundary?: boolean; 64 | intervalUTC?: boolean; 65 | maxFiles?: number; 66 | maxSize?: string; 67 | mode?: number; 68 | omitExtension?: boolean; 69 | path?: string; 70 | rotate?: number; 71 | size?: string; 72 | teeToStdout?: boolean; 73 | } 74 | 75 | interface Opts { 76 | compress?: boolean | string | Compressor; 77 | encoding?: BufferEncoding; 78 | history?: string; 79 | immutable?: boolean; 80 | initialRotation?: boolean; 81 | interval?: { num: number; unit: string }; 82 | intervalBoundary?: boolean; 83 | intervalUTC?: boolean; 84 | maxFiles?: number; 85 | maxSize?: number; 86 | mode?: number; 87 | omitExtension?: boolean; 88 | path?: string; 89 | rotate?: number; 90 | size?: number; 91 | teeToStdout?: boolean; 92 | } 93 | 94 | type Callback = (error?: Error) => void; 95 | 96 | interface Chunk { 97 | chunk: Buffer; 98 | encoding: BufferEncoding; 99 | } 100 | 101 | interface History { 102 | name: string; 103 | size: number; 104 | time: number; 105 | } 106 | 107 | export class RotatingFileStream extends Writable { 108 | private createGzip: () => Gzip; 109 | private exec: typeof exec; 110 | private file: FileHandle | undefined; 111 | private filename: string; 112 | private finished: boolean; 113 | private fsCreateReadStream: typeof createReadStream; 114 | private fsCreateWriteStream: typeof createWriteStream; 115 | private fsOpen: typeof open; 116 | private fsReadFile: typeof readFile; 117 | private fsStat: typeof stat; 118 | private fsUnlink: typeof unlink; 119 | private generator: Generator; 120 | private initPromise: Promise | null; 121 | private last: string; 122 | private maxTimeout: number; 123 | private next: number; 124 | private options: Opts; 125 | private prev: number; 126 | private rotation: Date; 127 | private size: number; 128 | private stdout: typeof process.stdout; 129 | private timeout: NodeJS.Timeout; 130 | private timeoutPromise: Promise | null; 131 | 132 | constructor(generator: Generator, options: Opts) { 133 | const { encoding, history, maxFiles, maxSize, path } = options; 134 | 135 | super({ decodeStrings: true, defaultEncoding: encoding }); 136 | 137 | this.createGzip = createGzip; 138 | this.exec = exec; 139 | this.filename = path + generator(null); 140 | this.fsCreateReadStream = createReadStream; 141 | this.fsCreateWriteStream = createWriteStream; 142 | this.fsOpen = open; 143 | this.fsReadFile = readFile; 144 | this.fsStat = stat; 145 | this.fsUnlink = unlink; 146 | this.generator = generator; 147 | this.maxTimeout = 2147483640; 148 | this.options = options; 149 | this.stdout = process.stdout; 150 | 151 | if(maxFiles || maxSize) options.history = path + (history ? history : this.generator(null) + ".txt"); 152 | 153 | this.on("close", () => (this.finished ? null : this.emit("finish"))); 154 | this.on("finish", () => (this.finished = this.clear())); 155 | 156 | // In v15 was introduced the _constructor method to delay any _write(), _final() and _destroy() calls 157 | // Until v16 will be not deprecated we still need this.initPromise 158 | // https://nodejs.org/api/stream.html#stream_writable_construct_callback 159 | 160 | (async () => { 161 | try { 162 | this.initPromise = this.init(); 163 | 164 | await this.initPromise; 165 | delete this.initPromise; 166 | } catch(e) {} 167 | })(); 168 | } 169 | 170 | _destroy(error: Error, callback: Callback): void { 171 | this.refinal(error, callback); 172 | } 173 | 174 | _final(callback: Callback): void { 175 | this.refinal(undefined, callback); 176 | } 177 | 178 | _write(chunk: Buffer, encoding: BufferEncoding, callback: Callback): void { 179 | this.rewrite([{ chunk, encoding }], 0, callback); 180 | } 181 | 182 | _writev(chunks: Chunk[], callback: Callback): void { 183 | this.rewrite(chunks, 0, callback); 184 | } 185 | 186 | private async refinal(error: Error | undefined, callback: Callback): Promise { 187 | try { 188 | this.clear(); 189 | 190 | if(this.initPromise) await this.initPromise; 191 | if(this.timeoutPromise) await this.timeoutPromise; 192 | 193 | await this.reclose(); 194 | } catch(e) { 195 | return callback(error || e); 196 | } 197 | 198 | callback(error); 199 | } 200 | 201 | private async rewrite(chunks: Chunk[], index: number, callback: Callback): Promise { 202 | const { size, teeToStdout } = this.options; 203 | 204 | try { 205 | if(this.initPromise) await this.initPromise; 206 | 207 | for(let i = 0; i < chunks.length; ++i) { 208 | const { chunk } = chunks[i]; 209 | 210 | this.size += chunk.length; 211 | if(this.timeoutPromise) await this.timeoutPromise; 212 | await this.file.write(chunk); 213 | 214 | if(teeToStdout && ! this.stdout.destroyed) this.stdout.write(chunk); 215 | if(size && this.size >= size) await this.rotate(); 216 | } 217 | } catch(e) { 218 | return callback(e); 219 | } 220 | 221 | callback(); 222 | } 223 | 224 | private async init(): Promise { 225 | const { immutable, initialRotation, interval, size } = this.options; 226 | 227 | // In v15 was introduced the _constructor method to delay any _write(), _final() and _destroy() calls 228 | // Once v16 will be deprecated we can restore only following line 229 | // if(immutable) return this.immutate(true); 230 | if(immutable) return new Promise((resolve, reject) => process.nextTick(() => this.immutate(true).then(resolve).catch(reject))); 231 | 232 | let stats: Stats; 233 | 234 | try { 235 | stats = await stat(this.filename); 236 | } catch(e) { 237 | if(e.code !== "ENOENT") throw e; 238 | 239 | return this.reopen(0); 240 | } 241 | 242 | if(! stats.isFile()) throw new Error(`Can't write on: ${this.filename} (it is not a file)`); 243 | 244 | if(initialRotation) { 245 | this.intervalBounds(this.now()); 246 | const prev = this.prev; 247 | this.intervalBounds(new Date(stats.mtime.getTime())); 248 | 249 | if(prev !== this.prev) return this.rotate(); 250 | } 251 | 252 | this.size = stats.size; 253 | if(! size || stats.size < size) return this.reopen(stats.size); 254 | if(interval) this.intervalBounds(this.now()); 255 | 256 | return this.rotate(); 257 | } 258 | 259 | private async makePath(name: string): Promise { 260 | return mkdir(name.split(sep).slice(0, -1).join(sep), { recursive: true }); 261 | } 262 | 263 | private async reopen(size: number): Promise { 264 | let file: FileHandle; 265 | 266 | try { 267 | file = await open(this.filename, "a", this.options.mode); 268 | } catch(e) { 269 | if(e.code !== "ENOENT") throw e; 270 | 271 | await this.makePath(this.filename); 272 | file = await open(this.filename, "a", this.options.mode); 273 | } 274 | 275 | this.file = file; 276 | this.size = size; 277 | this.interval(); 278 | this.emit("open", this.filename); 279 | } 280 | 281 | private async reclose(): Promise { 282 | const { file } = this; 283 | 284 | if(! file) return; 285 | 286 | delete this.file; 287 | return file.close(); 288 | } 289 | 290 | private now(): Date { 291 | return new Date(); 292 | } 293 | 294 | private async rotate(): Promise { 295 | const { immutable, rotate } = this.options; 296 | 297 | this.size = 0; 298 | this.rotation = this.now(); 299 | 300 | this.clear(); 301 | this.emit("rotation"); 302 | await this.reclose(); 303 | 304 | if(rotate) return this.classical(); 305 | if(immutable) return this.immutate(false); 306 | 307 | return this.move(); 308 | } 309 | 310 | private async findName(): Promise { 311 | const { interval, path, intervalBoundary } = this.options; 312 | 313 | for(let index = 1; index < 1000; ++index) { 314 | const filename = path + this.generator(interval && intervalBoundary ? new Date(this.prev) : this.rotation, index); 315 | 316 | if(! (await exists(filename))) return filename; 317 | } 318 | 319 | throw new RotatingFileStreamError(); 320 | } 321 | 322 | private async move(): Promise { 323 | const { compress } = this.options; 324 | 325 | const filename = await this.findName(); 326 | await this.touch(filename); 327 | 328 | if(compress) await this.compress(filename); 329 | else await rename(this.filename, filename); 330 | 331 | return this.rotated(filename); 332 | } 333 | 334 | private async touch(filename: string): Promise { 335 | let file: FileHandle; 336 | 337 | try { 338 | file = await this.fsOpen(filename, "a"); 339 | } catch(e) { 340 | if(e.code !== "ENOENT") throw e; 341 | 342 | await this.makePath(filename); 343 | file = await open(filename, "a"); 344 | } 345 | 346 | await file.close(); 347 | return this.unlink(filename); 348 | } 349 | 350 | private async classical(): Promise { 351 | const { compress, path, rotate } = this.options; 352 | let rotatedName = ""; 353 | 354 | for(let count = rotate; count > 0; --count) { 355 | const currName = path + this.generator(count); 356 | const prevName = count === 1 ? this.filename : path + this.generator(count - 1); 357 | 358 | if(! (await exists(prevName))) continue; 359 | if(! rotatedName) rotatedName = currName; 360 | 361 | if(count === 1 && compress) await this.compress(currName); 362 | else { 363 | try { 364 | await rename(prevName, currName); 365 | } catch(e) { 366 | if(e.code !== "ENOENT") throw e; 367 | 368 | await this.makePath(currName); 369 | await rename(prevName, currName); 370 | } 371 | } 372 | } 373 | 374 | return this.rotated(rotatedName); 375 | } 376 | 377 | private clear(): boolean { 378 | if(this.timeout) { 379 | clearTimeout(this.timeout); 380 | this.timeout = null; 381 | } 382 | 383 | return true; 384 | } 385 | 386 | private intervalBoundsBig(now: Date): void { 387 | const year = this.options.intervalUTC ? now.getUTCFullYear() : now.getFullYear(); 388 | let month = this.options.intervalUTC ? now.getUTCMonth() : now.getMonth(); 389 | let day = this.options.intervalUTC ? now.getUTCDate() : now.getDate(); 390 | let hours = this.options.intervalUTC ? now.getUTCHours() : now.getHours(); 391 | const { num, unit } = this.options.interval; 392 | 393 | if(unit === "M") { 394 | day = 1; 395 | hours = 0; 396 | } else if(unit === "d") hours = 0; 397 | else hours = parseInt((hours / num) as unknown as string, 10) * num; 398 | 399 | this.prev = new Date(year, month, day, hours, 0, 0, 0).getTime(); 400 | 401 | if(unit === "M") month += num; 402 | else if(unit === "d") day += num; 403 | else hours += num; 404 | 405 | this.next = new Date(year, month, day, hours, 0, 0, 0).getTime(); 406 | } 407 | 408 | private intervalBounds(now: Date): Date { 409 | const unit = this.options.interval.unit; 410 | 411 | if(unit === "M" || unit === "d" || unit === "h") this.intervalBoundsBig(now); 412 | else { 413 | let period = 1000 * this.options.interval.num; 414 | 415 | if(unit === "m") period *= 60; 416 | 417 | this.prev = parseInt((now.getTime() / period) as unknown as string, 10) * period; 418 | this.next = this.prev + period; 419 | } 420 | 421 | return new Date(this.prev); 422 | } 423 | 424 | private interval(): void { 425 | if(! this.options.interval) return; 426 | 427 | this.intervalBounds(this.now()); 428 | 429 | const set = async (): Promise => { 430 | const time = this.next - this.now().getTime(); 431 | 432 | if(time <= 0) { 433 | try { 434 | this.timeoutPromise = this.rotate(); 435 | 436 | await this.timeoutPromise; 437 | delete this.timeoutPromise; 438 | } catch(e) {} 439 | } else { 440 | this.timeout = setTimeout(set, time > this.maxTimeout ? this.maxTimeout : time); 441 | this.timeout.unref(); 442 | } 443 | }; 444 | 445 | set(); 446 | } 447 | 448 | private async compress(filename: string): Promise { 449 | const { compress } = this.options; 450 | 451 | if(typeof compress === "function") { 452 | await new Promise((resolve, reject) => { 453 | this.exec(compress(this.filename, filename), (error, stdout, stderr) => { 454 | this.emit("external", stdout, stderr); 455 | error ? reject(error) : resolve(); 456 | }); 457 | }); 458 | } else await this.gzip(filename); 459 | 460 | return this.unlink(this.filename); 461 | } 462 | 463 | private async gzip(filename: string): Promise { 464 | const { mode } = this.options; 465 | const options = mode ? { mode } : {}; 466 | const inp = this.fsCreateReadStream(this.filename, {}); 467 | const out = this.fsCreateWriteStream(filename, options); 468 | const zip = this.createGzip(); 469 | 470 | return new Promise((resolve, reject) => { 471 | inp.once("error", reject); 472 | out.once("error", reject); 473 | zip.once("error", reject); 474 | out.once("finish", resolve); 475 | inp.pipe(zip).pipe(out); 476 | }); 477 | } 478 | 479 | private async rotated(filename: string): Promise { 480 | const { maxFiles, maxSize } = this.options; 481 | 482 | if(maxFiles || maxSize) await this.history(filename); 483 | 484 | this.emit("rotated", filename); 485 | 486 | return this.reopen(0); 487 | } 488 | 489 | private async history(filename: string): Promise { 490 | const { history, maxFiles, maxSize } = this.options; 491 | const res: History[] = []; 492 | let files = [filename]; 493 | 494 | try { 495 | const content = await this.fsReadFile(history, "utf8"); 496 | 497 | files = [...content.toString().split("\n"), filename]; 498 | } catch(e) { 499 | if(e.code !== "ENOENT") throw e; 500 | } 501 | 502 | for(const file of files) { 503 | if(file) { 504 | try { 505 | const stats = await this.fsStat(file); 506 | 507 | if(stats.isFile()) { 508 | res.push({ 509 | name: file, 510 | size: stats.size, 511 | time: stats.mtime.getTime() 512 | }); 513 | } else this.emit("warning", new Error(`File '${file}' contained in history is not a regular file`)); 514 | } catch(e) { 515 | if(e.code !== "ENOENT") throw e; 516 | } 517 | } 518 | } 519 | 520 | res.sort((a, b) => a.time - b.time); 521 | 522 | if(maxFiles) { 523 | while(res.length > maxFiles) { 524 | const file = res.shift(); 525 | 526 | await this.unlink(file.name); 527 | this.emit("removed", file.name, true); 528 | } 529 | } 530 | 531 | if(maxSize) { 532 | while(res.reduce((size, file) => size + file.size, 0) > maxSize) { 533 | const file = res.shift(); 534 | 535 | await this.unlink(file.name); 536 | this.emit("removed", file.name, false); 537 | } 538 | } 539 | 540 | await writeFile(history, res.map(e => e.name).join("\n") + "\n", "utf-8"); 541 | this.emit("history"); 542 | } 543 | 544 | private async immutate(first: boolean): Promise { 545 | const { size } = this.options; 546 | const now = this.now(); 547 | 548 | for(let index = 1; index < 1000; ++index) { 549 | let fileSize = 0; 550 | let stats: Stats = undefined; 551 | 552 | this.filename = this.options.path + this.generator(now, index); 553 | 554 | try { 555 | stats = await this.fsStat(this.filename); 556 | } catch(e) { 557 | if(e.code !== "ENOENT") throw e; 558 | } 559 | 560 | if(stats) { 561 | fileSize = stats.size; 562 | 563 | if(! stats.isFile()) throw new Error(`Can't write on: '${this.filename}' (it is not a file)`); 564 | if(size && fileSize >= size) continue; 565 | } 566 | 567 | if(first) { 568 | this.last = this.filename; 569 | 570 | return this.reopen(fileSize); 571 | } 572 | 573 | await this.rotated(this.last); 574 | this.last = this.filename; 575 | 576 | return; 577 | } 578 | 579 | throw new RotatingFileStreamError(); 580 | } 581 | 582 | private async unlink(filename: string): Promise { 583 | try { 584 | await this.fsUnlink(filename); 585 | } catch(e) { 586 | if(e.code !== "ENOENT") throw e; 587 | 588 | this.emit("warning", e); 589 | } 590 | } 591 | } 592 | 593 | function buildNumberCheck(field: string): (type: string, options: Options, value: string) => void { 594 | return (type: string, options: Options, value: string): void => { 595 | const converted: number = parseInt(value, 10); 596 | 597 | if(type !== "number" || (converted as unknown) !== value || converted <= 0) throw new Error(`'${field}' option must be a positive integer number`); 598 | }; 599 | } 600 | 601 | function buildStringCheck(field: keyof Options, check: (value: string) => any) { 602 | return (type: string, options: Options, value: string): void => { 603 | if(type !== "string") throw new Error(`Don't know how to handle 'options.${field}' type: ${type}`); 604 | 605 | options[field] = check(value) as never; 606 | }; 607 | } 608 | 609 | function checkMeasure(value: string, what: string, units: any): any { 610 | const ret: any = {}; 611 | 612 | ret.num = parseInt(value, 10); 613 | 614 | if(isNaN(ret.num)) throw new Error(`Unknown 'options.${what}' format: ${value}`); 615 | if(ret.num <= 0) throw new Error(`A positive integer number is expected for 'options.${what}'`); 616 | 617 | ret.unit = value.replace(/^[ 0]*/g, "").substr((ret.num + "").length, 1); 618 | 619 | if(ret.unit.length === 0) throw new Error(`Missing unit for 'options.${what}'`); 620 | if(! units[ret.unit]) throw new Error(`Unknown 'options.${what}' unit: ${ret.unit}`); 621 | 622 | return ret; 623 | } 624 | 625 | const intervalUnits: any = { M: true, d: true, h: true, m: true, s: true }; 626 | 627 | function checkIntervalUnit(ret: any, unit: string, amount: number): void { 628 | if(parseInt((amount / ret.num) as unknown as string, 10) * ret.num !== amount) throw new Error(`An integer divider of ${amount} is expected as ${unit} for 'options.interval'`); 629 | } 630 | 631 | function checkInterval(value: string): any { 632 | const ret = checkMeasure(value, "interval", intervalUnits); 633 | 634 | switch(ret.unit) { 635 | case "h": 636 | checkIntervalUnit(ret, "hours", 24); 637 | break; 638 | 639 | case "m": 640 | checkIntervalUnit(ret, "minutes", 60); 641 | break; 642 | 643 | case "s": 644 | checkIntervalUnit(ret, "seconds", 60); 645 | break; 646 | } 647 | 648 | return ret; 649 | } 650 | 651 | const sizeUnits: any = { B: true, G: true, K: true, M: true }; 652 | 653 | function checkSize(value: string): any { 654 | const ret = checkMeasure(value, "size", sizeUnits); 655 | 656 | if(ret.unit === "K") return ret.num * 1024; 657 | if(ret.unit === "M") return ret.num * 1048576; 658 | if(ret.unit === "G") return ret.num * 1073741824; 659 | 660 | return ret.num; 661 | } 662 | 663 | const checks: any = { 664 | encoding: (type: string, options: Opts, value: string): any => new TextDecoder(value), 665 | immutable: (): void => {}, 666 | initialRotation: (): void => {}, 667 | interval: buildStringCheck("interval", checkInterval), 668 | intervalBoundary: (): void => {}, 669 | intervalUTC: (): void => {}, 670 | maxFiles: buildNumberCheck("maxFiles"), 671 | maxSize: buildStringCheck("maxSize", checkSize), 672 | mode: (): void => {}, 673 | omitExtension: (): void => {}, 674 | rotate: buildNumberCheck("rotate"), 675 | size: buildStringCheck("size", checkSize), 676 | teeToStdout: (): void => {}, 677 | ...{ 678 | compress: (type: string, options: Opts, value: boolean | string | Compressor): any => { 679 | if(value === false) return; 680 | if(! value) throw new Error("A value for 'options.compress' must be specified"); 681 | if(type === "boolean") return (options.compress = (source: string, dest: string): string => `cat ${source} | gzip -c9 > ${dest}`); 682 | if(type === "function") return; 683 | if(type !== "string") throw new Error(`Don't know how to handle 'options.compress' type: ${type}`); 684 | if((value as unknown as string) !== "gzip") throw new Error(`Don't know how to handle compression method: ${value}`); 685 | }, 686 | history: (type: string): void => { 687 | if(type !== "string") throw new Error(`Don't know how to handle 'options.history' type: ${type}`); 688 | }, 689 | path: (type: string, options: Opts, value: string): void => { 690 | if(type !== "string") throw new Error(`Don't know how to handle 'options.path' type: ${type}`); 691 | if(value[value.length - 1] !== sep) options.path = value + sep; 692 | } 693 | } 694 | }; 695 | 696 | function checkOpts(options: Options): Opts { 697 | const ret: Opts = {}; 698 | let opt: keyof Options; 699 | 700 | for(opt in options) { 701 | const value = options[opt]; 702 | const type = typeof value; 703 | 704 | if(! (opt in checks)) throw new Error(`Unknown option: ${opt}`); 705 | 706 | ret[opt] = options[opt] as never; 707 | checks[opt](type, ret, value); 708 | } 709 | 710 | if(! ret.path) ret.path = ""; 711 | 712 | if(! ret.interval) { 713 | delete ret.immutable; 714 | delete ret.initialRotation; 715 | delete ret.intervalBoundary; 716 | delete ret.intervalUTC; 717 | } 718 | 719 | if(ret.rotate) { 720 | delete ret.history; 721 | delete ret.immutable; 722 | delete ret.maxFiles; 723 | delete ret.maxSize; 724 | delete ret.intervalBoundary; 725 | delete ret.intervalUTC; 726 | } 727 | 728 | if(ret.immutable) delete ret.compress; 729 | if(! ret.intervalBoundary) delete ret.initialRotation; 730 | 731 | return ret; 732 | } 733 | 734 | function createClassical(filename: string, compress: boolean, omitExtension: boolean): Generator { 735 | return (index: number): string => (index ? `${filename}.${index}${compress && ! omitExtension ? ".gz" : ""}` : filename); 736 | } 737 | 738 | function createGenerator(filename: string, compress: boolean, omitExtension: boolean): Generator { 739 | const pad = (num: number): string => (num > 9 ? "" : "0") + num; 740 | 741 | return (time: Date, index?: number): string => { 742 | if(! time) return filename as unknown as string; 743 | 744 | const month = time.getFullYear() + "" + pad(time.getMonth() + 1); 745 | const day = pad(time.getDate()); 746 | const hour = pad(time.getHours()); 747 | const minute = pad(time.getMinutes()); 748 | 749 | return month + day + "-" + hour + minute + "-" + pad(index) + "-" + filename + (compress && ! omitExtension ? ".gz" : ""); 750 | }; 751 | } 752 | 753 | export function createStream(filename: string | Generator, options?: Options): RotatingFileStream { 754 | if(typeof options === "undefined") options = {}; 755 | else if(typeof options !== "object") throw new Error(`The "options" argument must be of type object. Received type ${typeof options}`); 756 | 757 | const opts = checkOpts(options); 758 | const { compress, omitExtension } = opts; 759 | let generator: Generator; 760 | 761 | if(typeof filename === "string") generator = options.rotate ? createClassical(filename, !! compress, omitExtension) : createGenerator(filename, !! compress, omitExtension); 762 | else if(typeof filename === "function") generator = filename; 763 | else throw new Error(`The "filename" argument must be one of type string or function. Received type ${typeof filename}`); 764 | 765 | return new RotatingFileStream(generator, opts); 766 | } 767 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: ["index.ts"], 3 | preset: "ts-jest", 4 | testEnvironment: "jest-environment-node-single-context", 5 | testSequencer: "./testSequencer.cjs" 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rotating-file-stream", 3 | "version": "3.2.6", 4 | "description": "Opens a stream.Writable to a file rotated by interval and/or size. A logrotate alternative.", 5 | "scripts": { 6 | "all": "yarn eslint && yarn coverage", 7 | "coverage": "rm -rf dist && TZ=Europe/Rome jest --coverage --runInBand", 8 | "eslint": "eslint index.ts utils.ts test/*ts", 9 | "ignore": "tsx utils ignore", 10 | "prepare": "npm run ignore && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.types.json", 11 | "test": "rm -rf dist && TZ=Europe/Rome jest --runInBand" 12 | }, 13 | "bugs": "https://github.com/iccicci/rotating-file-stream/issues", 14 | "repository": "https://github.com/iccicci/rotating-file-stream", 15 | "keywords": [ 16 | "log", 17 | "rotate", 18 | "logrotate" 19 | ], 20 | "engines": { 21 | "node": ">=14.0" 22 | }, 23 | "author": "Daniele Ricci (https://github.com/iccicci)", 24 | "contributors": [ 25 | "cicci (https://www.trinityteam.it/DanieleRicci#en)", 26 | "allevo", 27 | "kbirger", 28 | "jvassev", 29 | "wangao", 30 | "rakshith-ravi", 31 | "Jorge Silva ", 32 | "Jan Christoph Bernack ", 33 | "cchare (https://github.com/cchare)" 34 | ], 35 | "license": "MIT", 36 | "funding": { 37 | "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN" 38 | }, 39 | "readmeFilename": "README.md", 40 | "main": "./dist/cjs/index.js", 41 | "module": "./dist/esm/index.js", 42 | "types": "./dist/types/index.d.ts", 43 | "exports": { 44 | ".": { 45 | "import": "./dist/esm/index.js", 46 | "require": "./dist/cjs/index.js", 47 | "types": "./dist/types/index.d.ts" 48 | } 49 | }, 50 | "type": "module", 51 | "devDependencies": { 52 | "@types/jest": "29.5.14", 53 | "@types/node": "22.13.1", 54 | "@typescript-eslint/eslint-plugin": "6.16.0", 55 | "@typescript-eslint/parser": "6.16.0", 56 | "eslint": "8.56.0", 57 | "eslint-plugin-sort-keys": "2.3.5", 58 | "jest": "29.7.0", 59 | "jest-environment-node-single-context": "29.4.0", 60 | "prettier": "3.5.0", 61 | "ts-jest": "29.2.5", 62 | "tsx": "4.19.2", 63 | "typescript": "5.7.3" 64 | }, 65 | "prettier": { 66 | "arrowParens": "avoid", 67 | "jsxBracketSameLine": true, 68 | "printWidth": 200, 69 | "trailingComma": "none" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/01rfs.test.ts: -------------------------------------------------------------------------------- 1 | process.env.TZ = "Europe/Rome"; 2 | 3 | import { RotatingFileStream, createStream } from ".."; 4 | import { strictEqual as eq, throws as ex } from "assert"; 5 | import { Writable } from "stream"; 6 | 7 | describe("rfs", () => { 8 | describe("new", () => { 9 | let rfs: any; 10 | 11 | beforeAll(done => { 12 | rfs = createStream("test.log", { mode: parseInt("666", 8) }); 13 | rfs.end(done); 14 | }); 15 | 16 | it("RFS", () => eq(rfs instanceof RotatingFileStream, true)); 17 | it("Writable", () => eq(rfs instanceof Writable, true)); 18 | it("std filename generator first time", () => eq(rfs.generator(null), "test.log")); 19 | it("std filename generator later times", () => eq(rfs.generator(new Date("1976-01-23 14:45"), 4), "19760123-1445-04-test.log")); 20 | }); 21 | 22 | describe("no options", () => { 23 | beforeAll(done => { 24 | createStream("test.log").end(done); 25 | }); 26 | 27 | it("no error", () => eq(true, true)); 28 | }); 29 | 30 | describe("wrong calls", () => { 31 | const encodingError = RangeError('The "test" encoding is not supported'); 32 | 33 | if(Number(process.version.match(/^v(\d+)/)![1]) < 11) encodingError.name = "RangeError [ERR_ENCODING_NOT_SUPPORTED]"; 34 | 35 | it("wrong filename type", () => ex(() => createStream({} as string), Error('The "filename" argument must be one of type string or function. Received type object'))); 36 | it("wrong options type", () => ex(() => createStream("test.log", "test.log" as never), Error('The "options" argument must be of type object. Received type string'))); 37 | it("unknown option", () => ex(() => createStream("test.log", { test: true } as any), Error("Unknown option: test"))); 38 | it("no compress value", () => ex(() => createStream("test.log", { compress: undefined }), Error("A value for 'options.compress' must be specified"))); 39 | it("wrong compress type", () => ex(() => createStream("test.log", { compress: 23 } as any), Error("Don't know how to handle 'options.compress' type: number"))); 40 | it("wrong compress method", () => ex(() => createStream("test.log", { compress: "test" }), Error("Don't know how to handle compression method: test"))); 41 | it("wrong interval type", () => ex(() => createStream("test.log", { interval: 23 } as any), Error("Don't know how to handle 'options.interval' type: number"))); 42 | it("wrong path type", () => ex(() => createStream("test.log", { path: 23 } as any), Error("Don't know how to handle 'options.path' type: number"))); 43 | it("wrong size type", () => ex(() => createStream("test.log", { size: 23 } as any), Error("Don't know how to handle 'options.size' type: number"))); 44 | it("wrong size type", () => ex(() => createStream("test.log", { size: "test" }), Error("Unknown 'options.size' format: test"))); 45 | it("wrong size number", () => ex(() => createStream("test.log", { size: "-23B" }), Error("A positive integer number is expected for 'options.size'"))); 46 | it("missing size unit", () => ex(() => createStream("test.log", { size: "23" }), Error("Missing unit for 'options.size'"))); 47 | it("wrong size unit", () => ex(() => createStream("test.log", { size: "23test" }), Error("Unknown 'options.size' unit: t"))); 48 | it("wrong interval seconds number", () => ex(() => createStream("test.log", { interval: "23s" }), Error("An integer divider of 60 is expected as seconds for 'options.interval'"))); 49 | it("wrong interval minutes number", () => ex(() => createStream("test.log", { interval: "23m" }), Error("An integer divider of 60 is expected as minutes for 'options.interval'"))); 50 | it("wrong interval hours number", () => ex(() => createStream("test.log", { interval: "23h" }), Error("An integer divider of 24 is expected as hours for 'options.interval'"))); 51 | it("string rotate value", () => ex(() => createStream("test.log", { rotate: "test" } as any), Error("'rotate' option must be a positive integer number"))); 52 | it("negative rotate value", () => ex(() => createStream("test.log", { rotate: -23 }), Error("'rotate' option must be a positive integer number"))); 53 | it("wrong history", () => ex(() => createStream("test.log", { history: {} } as any), Error("Don't know how to handle 'options.history' type: object"))); 54 | it("wrong maxFiles", () => ex(() => createStream("test.log", { maxFiles: {} } as any), Error("'maxFiles' option must be a positive integer number"))); 55 | it("negative maxFiles", () => ex(() => createStream("test.log", { maxFiles: -23 }), Error("'maxFiles' option must be a positive integer number"))); 56 | it("wrong maxSize", () => ex(() => createStream("test.log", { maxSize: "-23B" }), Error("A positive integer number is expected for 'options.size'"))); 57 | it("wrong encoding", () => ex(() => createStream("test.log", { encoding: "test" as BufferEncoding }), encodingError)); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/02write.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 2 | import { readFileSync } from "fs"; 3 | import { createStream } from ".."; 4 | import { test } from "./helper"; 5 | 6 | describe("write(s)", () => { 7 | describe("single write", () => { 8 | const events = test({}, rfs => rfs.end("test\n")); 9 | 10 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 11 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 12 | }); 13 | 14 | describe("multi write", () => { 15 | const events = test({ files: { "test.log": "test\n" } }, rfs => { 16 | rfs.write("test\n"); 17 | rfs.write("test\n"); 18 | rfs.end("test\n"); 19 | }); 20 | 21 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1, writev: 1 })); 22 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\ntest\ntest\ntest\n")); 23 | }); 24 | 25 | describe("end callback", function() { 26 | const events = test({}, rfs => { 27 | rfs.end("test\n", "utf8", () => (events.endCb = true)); 28 | }); 29 | 30 | it("events", () => deq(events, { close: 1, endCb: true, finish: 1, open: ["test.log"], write: 1 })); 31 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 32 | }); 33 | 34 | describe("write after open", function() { 35 | const events = test({}, rfs => rfs.once("open", () => rfs.end("test\n", "utf8"))); 36 | 37 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 38 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 39 | }); 40 | 41 | describe("destroy before open", function() { 42 | let stream: any; 43 | let open: boolean; 44 | 45 | const event = (done?: any): void => { 46 | open = true; 47 | if(done) done(); 48 | }; 49 | 50 | const events = test({}, rfs => { 51 | stream = rfs; 52 | rfs.on("open", () => event()); 53 | rfs.destroy(); 54 | rfs.write("test\n"); 55 | }); 56 | 57 | beforeAll(done => { 58 | if(open) return done(); 59 | stream.on("open", () => event(done)); 60 | }); 61 | 62 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"] })); 63 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 64 | }); 65 | 66 | describe("destroy between open and write", function() { 67 | const events = test({}, rfs => 68 | rfs.once("open", () => { 69 | rfs.destroy(); 70 | rfs.write("test\n"); 71 | }) 72 | ); 73 | 74 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"] })); 75 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 76 | }); 77 | 78 | describe("destroy while writing", function() { 79 | const events = test({}, rfs => 80 | rfs.once("open", () => { 81 | rfs.write("test\n"); 82 | rfs.destroy(); 83 | }) 84 | ); 85 | 86 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 87 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 88 | }); 89 | 90 | describe("destroy after write", function() { 91 | const events = test({}, rfs => 92 | rfs.once("open", () => { 93 | rfs.write("test\n", () => rfs.destroy()); 94 | }) 95 | ); 96 | 97 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 98 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 99 | }); 100 | 101 | describe("two consecutive open in not existing directory", function() { 102 | let count = 0; 103 | 104 | beforeAll(function(done) { 105 | const rfs1 = createStream("log/test1"); 106 | const rfs2 = createStream("log/test2"); 107 | 108 | const open = () => (++count === 2 ? rfs1.end(() => rfs2.end(done)) : null); 109 | 110 | rfs1.on("open", open); 111 | rfs2.on("open", open); 112 | }); 113 | 114 | it("2 opens", () => eq(2, count)); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /test/03size.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 2 | import { readFileSync } from "fs"; 3 | import { sep } from "path"; 4 | import { test } from "./helper"; 5 | 6 | describe("size", () => { 7 | describe("initial rotation", () => { 8 | const events = test({ files: { "test.log": "test\ntest\n" }, options: { size: "10B" } }, rfs => rfs.end("test\n")); 9 | 10 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], rotated: ["1-test.log"], rotation: 1, write: 1 })); 11 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 12 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 13 | }); 14 | 15 | describe("single write rotation by size", () => { 16 | const events = test({ files: { "test.log": "test\n" }, options: { size: "10B" } }, rfs => rfs.end("test\n")); 17 | 18 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1 })); 19 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 20 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 21 | }); 22 | 23 | describe("multi write rotation by size", () => { 24 | const events = test({ options: { size: "10B" } }, rfs => { 25 | rfs.write("test\n"); 26 | rfs.write("test\n"); 27 | rfs.end("test\n"); 28 | }); 29 | 30 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1, writev: 1 })); 31 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 32 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 33 | }); 34 | 35 | describe("one write one file", () => { 36 | const events = test({ files: { "test.log": "test\n" }, options: { size: "15B" } }, rfs => { 37 | rfs.write("test\n"); 38 | rfs.write("test\ntest\n"); 39 | rfs.end("test\n"); 40 | }); 41 | 42 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1, writev: 1 })); 43 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 44 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\ntest\ntest\n")); 45 | }); 46 | 47 | describe("missing path creation", function() { 48 | const filename = `log${sep}t${sep}test.log`; 49 | const rotated = `log${sep}t${sep}t${sep}test.log`; 50 | const events = test({ filename: (time: number | Date): string => (time ? rotated : filename), options: { size: "10B" } }, rfs => { 51 | rfs.write("test\n"); 52 | rfs.write("test\n"); 53 | rfs.end("test\n"); 54 | }); 55 | 56 | it("events", () => deq(events, { close: 1, finish: 1, open: [filename, filename], rotated: [rotated], rotation: 1, write: 1, writev: 1 })); 57 | it("file content", () => eq(readFileSync(filename, "utf8"), "test\n")); 58 | it("rotated file content", () => eq(readFileSync(rotated, "utf8"), "test\ntest\n")); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/04errors.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import { deepStrictEqual as deq, strictEqual as eq, throws as ex } from "assert"; 3 | import { createStream } from ".."; 4 | import { sep } from "path"; 5 | import { test } from "./helper"; 6 | 7 | describe("errors", () => { 8 | describe("wrong name generator (first time)", () => { 9 | it("wrong filename type", () => 10 | ex( 11 | () => 12 | createStream(() => { 13 | throw new Error("test"); 14 | }), 15 | Error("test") 16 | )); 17 | }); 18 | 19 | describe("wrong name generator (rotation)", () => { 20 | const events = test( 21 | { 22 | filename: (time: number | Date) => { 23 | if(time) throw new Error("test"); 24 | return "test.log"; 25 | }, 26 | options: { size: "15B" } 27 | }, 28 | rfs => { 29 | [0, 0, 0, 0].map(() => rfs.write("test\n")); 30 | rfs.end("test\n"); 31 | } 32 | ); 33 | 34 | it("events", () => deq(events, { close: 1, error: ["test"], finish: 1, open: ["test.log"], rotation: 1, write: 1, writev: 1 })); 35 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\ntest\ntest\n")); 36 | }); 37 | 38 | describe("wrong name generator (immutable)", () => { 39 | const events = test( 40 | { 41 | filename: (time: number | Date) => { 42 | if(time) throw new Error("test"); 43 | return "test.log"; 44 | }, 45 | options: { immutable: true, interval: "1d", size: "5B" } 46 | }, 47 | rfs => { 48 | rfs.write("test\n"); 49 | rfs.end("test\n"); 50 | } 51 | ); 52 | 53 | it("events", () => deq(events, { close: 1, error: ["test"], finish: 1, write: 1 })); 54 | }); 55 | 56 | describe("logging on directory", () => { 57 | const events = test({ filename: "test", options: { size: "5B" } }, rfs => rfs.write("test\n")); 58 | 59 | it("events", () => deq(events, { close: 1, error: ["Can't write on: test (it is not a file)"], finish: 1, write: 1 })); 60 | }); 61 | 62 | describe("logging on directory (immutable)", () => { 63 | const events = test({ filename: () => "test", options: { immutable: true, interval: "1d", size: "5B" } }, rfs => rfs.write("test\n")); 64 | 65 | it("events", () => deq(events, { close: 1, error: ["Can't write on: 'test' (it is not a file)"], finish: 1, write: 1 })); 66 | }); 67 | 68 | describe("using file as directory", () => { 69 | const events = test({ filename: `index.ts${sep}test.log`, options: { size: "5B" } }, rfs => rfs.write("test\n")); 70 | 71 | it("events", () => deq(events, { close: 1, error: ["ENOTDIR"], finish: 1, write: 1 })); 72 | }); 73 | 74 | describe("no rotated file available", () => { 75 | const events = test({ filename: () => "test.log", options: { size: "5B" } }, rfs => rfs.write("test\n")); 76 | 77 | it("events", () => deq(events, { close: 1, error: ["RFS-TOO-MANY"], finish: 1, open: ["test.log"], rotation: 1, write: 1 })); 78 | }); 79 | 80 | describe("no rotated file available", () => { 81 | const events = test({ filename: () => "test.log", files: { "test.log": "test\n" }, options: { size: "5B" } }, rfs => rfs.write("test\n")); 82 | 83 | it("events", () => deq(events, { close: 1, error: ["RFS-TOO-MANY"], finish: 1, rotation: 1, write: 1 })); 84 | }); 85 | 86 | describe("error in stat (immutable)", () => { 87 | const events = test({ options: { immutable: true, interval: "1d", size: "5B" } }, rfs => { 88 | rfs.fsStat = async (path: string) => { 89 | throw new Error("test " + path); 90 | }; 91 | rfs.write("test\ntest\n"); 92 | }); 93 | 94 | it("events", () => deq(events, { close: 1, error: ["test 1-test.log"], finish: 1, write: 1 })); 95 | }); 96 | 97 | describe("immutable exhausted", () => { 98 | const events = test({ filename: () => "test.log", options: { immutable: true, interval: "1d", size: "5B" } }, rfs => rfs.write("test\n")); 99 | 100 | it("events", () => deq(events, { close: 1, error: ["RFS-TOO-MANY"], finish: 1, open: ["test.log"], rotation: 1, write: 1 })); 101 | }); 102 | 103 | describe("RO error", () => { 104 | const events = test({ files: { "test.log": { content: "test\n", mode: 0o400 } }, options: { size: "10B" } }, rfs => { 105 | rfs.write("test\n"); 106 | }); 107 | 108 | it("events", () => deq(events, { close: 1, error: ["EACCES"], finish: 1, write: 1 })); 109 | }); 110 | 111 | describe("error in timer after final", () => { 112 | const events = test({ options: { interval: "1s" } }, rfs => { 113 | rfs.fsOpen = async () => { 114 | throw new Error("test"); 115 | }; 116 | setTimeout(() => rfs.end(), 1000); 117 | }); 118 | 119 | it("events", () => deq(events, { close: 1, error: ["test"], finish: 1, open: ["test.log"], rotation: 1 })); 120 | }); 121 | 122 | describe("error while unlinking file", () => { 123 | const events = test({ options: { size: "10B" } }, rfs => { 124 | rfs.fsUnlink = async () => { 125 | throw new Error("test"); 126 | }; 127 | rfs.write("test\n"); 128 | rfs.write("test\n"); 129 | rfs.write("test\n"); 130 | }); 131 | 132 | it("events", () => deq(events, { close: 1, error: ["test"], finish: 1, open: ["test.log"], rotation: 1, write: 1, writev: 1 })); 133 | }); 134 | 135 | describe("ENOENT error while unlinking file", () => { 136 | const events = test({ options: { size: "10B" } }, rfs => { 137 | rfs.fsUnlink = async () => { 138 | const e = new Error("test"); 139 | 140 | (e as any).code = "ENOENT"; 141 | 142 | throw e; 143 | }; 144 | rfs.write("test\n"); 145 | rfs.write("test\n"); 146 | rfs.end("test\n"); 147 | }); 148 | 149 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, warning: ["test"], write: 1, writev: 1 })); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/05options.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 2 | import { gunzipSync } from "zlib"; 3 | import { readFileSync } from "fs"; 4 | import { sep } from "path"; 5 | import { test } from "./helper"; 6 | 7 | describe("options", () => { 8 | describe("size KiloBytes", () => { 9 | let size: number; 10 | const events = test({ options: { size: "10K" } }, rfs => rfs.end("test\n", () => (size = rfs.options.size))); 11 | 12 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 13 | it("10K", () => eq(size, 10240)); 14 | }); 15 | 16 | describe("size MegaBytes", () => { 17 | let size: number; 18 | const events = test({ options: { size: "10M" } }, rfs => rfs.end("test\n", () => (size = rfs.options.size))); 19 | 20 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 21 | it("10M", () => eq(size, 10485760)); 22 | }); 23 | 24 | describe("size GigaBytes", () => { 25 | let size: number; 26 | const events = test({ options: { size: "10G" } }, rfs => rfs.end("test\n", () => (size = rfs.options.size))); 27 | 28 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 29 | it("10G", () => eq(size, 10737418240)); 30 | }); 31 | 32 | describe("interval minutes", () => { 33 | let interval: number; 34 | const events = test({ options: { interval: "3m" } }, rfs => rfs.end("test\n", () => (interval = rfs.options.interval))); 35 | 36 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 37 | it("3'", () => deq(interval, { num: 3, unit: "m" })); 38 | }); 39 | 40 | describe("interval hours", () => { 41 | let interval: number, next: number, prev: number; 42 | const events = test({ options: { interval: "3h" } }, rfs => 43 | rfs.end("test\n", () => { 44 | interval = rfs.options.interval; 45 | rfs.intervalBounds(new Date(2015, 2, 29, 1, 29, 23, 123)); 46 | ({ next, prev } = rfs); 47 | }) 48 | ); 49 | 50 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 51 | it("3h", () => deq(interval, { num: 3, unit: "h" })); 52 | it("hours daylight saving", () => eq(next - prev, 7200000)); 53 | }); 54 | 55 | describe("interval days", () => { 56 | let interval: number, next: number, prev: number; 57 | const events = test({ options: { interval: "3d" } }, rfs => 58 | rfs.end("test\n", () => { 59 | interval = rfs.options.interval; 60 | rfs.intervalBounds(new Date(2015, 2, 29, 1, 29, 23, 123)); 61 | ({ next, prev } = rfs); 62 | }) 63 | ); 64 | 65 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1 })); 66 | it("3h", () => deq(interval, { num: 3, unit: "d" })); 67 | it("hours daylight saving", () => eq(next - prev, 255600000)); 68 | }); 69 | 70 | describe("path (ending)", () => { 71 | const filename = `log${sep}test.log`; 72 | const rotated = `log${sep}1-test.log`; 73 | const events = test({ options: { path: "log" + sep, size: "10B" } }, rfs => { 74 | rfs.write("test\n"); 75 | rfs.write("test\n"); 76 | rfs.end("test\n"); 77 | }); 78 | 79 | it("events", () => deq(events, { close: 1, finish: 1, open: [filename, filename], rotated: [rotated], rotation: 1, write: 1, writev: 1 })); 80 | it("file content", () => eq(readFileSync(filename, "utf8"), "test\n")); 81 | it("rotated file content", () => eq(readFileSync(rotated, "utf8"), "test\ntest\n")); 82 | }); 83 | 84 | describe("path (not ending)", () => { 85 | const filename = `log${sep}test.log`; 86 | const rotated = `log${sep}1-test.log`; 87 | const events = test({ options: { path: "log", size: "10B" } }, rfs => { 88 | rfs.write("test\n"); 89 | rfs.write("test\n"); 90 | rfs.end("test\n"); 91 | }); 92 | 93 | it("events", () => deq(events, { close: 1, finish: 1, open: [filename, filename], rotated: [rotated], rotation: 1, write: 1, writev: 1 })); 94 | it("file content", () => eq(readFileSync(filename, "utf8"), "test\n")); 95 | it("rotated file content", () => eq(readFileSync(rotated, "utf8"), "test\ntest\n")); 96 | }); 97 | 98 | describe("safe options object", () => { 99 | let options: any; 100 | const events = test({ options: { interval: "1d", rotate: 5, size: "10M" } }, rfs => { 101 | rfs.write("test\n"); 102 | rfs.write("test\n"); 103 | rfs.end("test\n"); 104 | options = rfs.options; 105 | }); 106 | 107 | it("options", () => deq(options, { interval: { num: 1, unit: "d" }, path: "", rotate: 5, size: 10485760 })); 108 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], write: 1, writev: 1 })); 109 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\ntest\ntest\n")); 110 | }); 111 | 112 | describe("immutable", () => { 113 | const events = test({ options: { immutable: true, interval: "1d", path: "log", size: "10B" } }, rfs => { 114 | rfs.write("test\n"); 115 | rfs.write("test\n"); 116 | rfs.end("test\n"); 117 | }); 118 | 119 | it("events", () => deq(events, { close: 1, finish: 1, open: ["log/1-test.log", "log/2-test.log"], rotated: ["log/1-test.log"], rotation: 1, write: 1, writev: 1 })); 120 | it("first file content", () => eq(readFileSync("log/1-test.log", "utf8"), "test\ntest\n")); 121 | it("second file content", () => eq(readFileSync("log/2-test.log", "utf8"), "test\n")); 122 | }); 123 | 124 | describe("immutable with file", () => { 125 | const events = test({ files: { "1-test.log": "test\n" }, options: { immutable: true, interval: "1d", size: "10B" } }, rfs => { 126 | rfs.write("test\n"); 127 | rfs.write("test\n"); 128 | rfs.write("test\n"); 129 | rfs.end("test\n"); 130 | }); 131 | 132 | it("events", () => deq(events, { close: 1, finish: 1, open: ["1-test.log", "2-test.log", "3-test.log"], rotated: ["1-test.log", "2-test.log"], rotation: 2, write: 1, writev: 1 })); 133 | it("first file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 134 | it("second file content", () => eq(readFileSync("2-test.log", "utf8"), "test\ntest\n")); 135 | it("third file content", () => eq(readFileSync("3-test.log", "utf8"), "test\n")); 136 | }); 137 | 138 | describe("teeToStdout", () => { 139 | const content: Buffer[] = []; 140 | 141 | const events = test({ options: { size: "10B", teeToStdout: true } }, rfs => { 142 | rfs.stdout = { write: (buffer: Buffer): number => content.push(buffer) }; 143 | rfs.write("test\n"); 144 | rfs.write("test\n"); 145 | rfs.end("test\n"); 146 | }); 147 | 148 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1, writev: 1 })); 149 | it("stdout", () => deq(content, [Buffer.from("test\n"), Buffer.from("test\n"), Buffer.from("test\n")])); 150 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 151 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 152 | }); 153 | 154 | describe("omitExtension", () => { 155 | const content: Buffer[] = []; 156 | 157 | const events = test({ options: { compress: "gzip", omitExtension: true, size: "10B" } }, rfs => { 158 | rfs.stdout = { write: (buffer: Buffer): number => content.push(buffer) }; 159 | rfs.write("test\n"); 160 | rfs.write("test\n"); 161 | rfs.end("test\n"); 162 | }); 163 | 164 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1, writev: 1 })); 165 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 166 | it("rotated file content", () => eq(gunzipSync(readFileSync("1-test.log")).toString(), "test\ntest\n")); 167 | }); 168 | 169 | describe("explicit false compress", () => { 170 | const content: Buffer[] = []; 171 | 172 | const events = test({ options: { compress: false, size: "10B" } }, rfs => { 173 | rfs.stdout = { write: (buffer: Buffer): number => content.push(buffer) }; 174 | rfs.write("test\n"); 175 | rfs.write("test\n"); 176 | rfs.end("test\n"); 177 | }); 178 | 179 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1, writev: 1 })); 180 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 181 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /test/06interval.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 2 | import { readFileSync } from "fs"; 3 | import { test } from "./helper"; 4 | 5 | describe("interval", () => { 6 | describe("initial rotation with interval", () => { 7 | const events = test({ filename: "test.log", files: { "test.log": "test\ntest\n" }, options: { interval: "1M", intervalBoundary: true, size: "10B" } }, rfs => { 8 | rfs.now = (): Date => new Date(2015, 2, 29, 1, 29, 23, 123); 9 | rfs.end("test\n"); 10 | }); 11 | 12 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], rotated: ["20150301-0000-01-test.log"], rotation: 1, write: 1 })); 13 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 14 | it("rotated file content", () => eq(readFileSync("20150301-0000-01-test.log", "utf8"), "test\ntest\n")); 15 | }); 16 | 17 | describe("intervalBoundary option", () => { 18 | const events = test({ filename: "test.log", files: { "test.log": "test\n" }, options: { initialRotation: true, interval: "1d", size: "10B" } }, rfs => { 19 | rfs.now = (): Date => new Date(2015, 2, 29, 1, 29, 23, 123); 20 | rfs.end("test\n"); 21 | }); 22 | 23 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["20150329-0129-01-test.log"], rotation: 1, write: 1 })); 24 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 25 | it("rotated file content", () => eq(readFileSync("20150329-0129-01-test.log", "utf8"), "test\ntest\n")); 26 | }); 27 | 28 | describe("initialRotation option", () => { 29 | const events = test( 30 | { 31 | filename: "test.log", 32 | files: { "test.log": { content: "test\n", date: new Date(2015, 0, 23, 1, 29, 23, 123) } }, 33 | options: { initialRotation: true, interval: "1d", intervalBoundary: true, size: "10B" } 34 | }, 35 | rfs => { 36 | rfs.now = (): Date => new Date(2015, 0, 29, 1, 29, 23, 123); 37 | rfs.end("test\n"); 38 | } 39 | ); 40 | 41 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], rotated: ["20150123-0000-01-test.log"], rotation: 1, write: 1 })); 42 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 43 | it("rotated file content", () => eq(readFileSync("20150123-0000-01-test.log", "utf8"), "test\n")); 44 | }); 45 | 46 | describe("initialRotation with intervalUTC", () => { 47 | const events = test( 48 | { 49 | filename: "test.log", 50 | files: { "test.log": { content: "test\n", date: new Date(2015, 0, 23, 12, 45) } }, 51 | options: { initialRotation: true, interval: "12h", intervalBoundary: true, intervalUTC: true, size: "10B" } 52 | }, 53 | rfs => { 54 | rfs.now = (): Date => new Date(2015, 0, 23, 13, 45); 55 | rfs.end("test\n"); 56 | } 57 | ); 58 | 59 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log"], rotated: ["20150123-0000-01-test.log"], rotation: 1, write: 1 })); 60 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 61 | it("rotated file content", () => eq(readFileSync("20150123-0000-01-test.log", "utf8"), "test\n")); 62 | }); 63 | 64 | describe("initialRotation option but ok", () => { 65 | const events = test( 66 | { 67 | filename: "test.log", 68 | files: { "test.log": { content: "test\n", date: new Date(2015, 2, 29, 1, 0, 0, 0) } }, 69 | options: { initialRotation: true, interval: "1d", intervalBoundary: true, size: "10B" } 70 | }, 71 | rfs => { 72 | rfs.now = (): Date => new Date(2015, 2, 29, 1, 29, 23, 123); 73 | rfs.end("test\n"); 74 | } 75 | ); 76 | 77 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["20150329-0000-01-test.log"], rotation: 1, write: 1 })); 78 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 79 | it("rotated file content", () => eq(readFileSync("20150329-0000-01-test.log", "utf8"), "test\ntest\n")); 80 | }); 81 | 82 | describe("write while rotation", () => { 83 | const events = test({ files: { "test.log": "test\ntest\n" }, options: { interval: "1s" } }, rfs => { 84 | let count = 0; 85 | rfs.now = (): Date => new Date(2015, 0, 23, 0, 0, 0, count++ ? 1000 : 990); 86 | rfs.once("rotation", () => rfs.end("test\n")); 87 | }); 88 | 89 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1 })); 90 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 91 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 92 | }); 93 | 94 | describe("_write while rotation", () => { 95 | const events = test({ files: { "test.log": "test\ntest\n" }, options: { interval: "1s" } }, rfs => { 96 | const prev = rfs._write; 97 | let count = 0; 98 | rfs.now = (): Date => new Date(2015, 0, 23, 0, 0, 0, count++ ? 1000 : 990); 99 | rfs._write = (chunk: any, encoding: any, callback: any): any => rfs.once("rotation", prev.bind(rfs, chunk, encoding, callback)); 100 | 101 | rfs.end("test\n"); 102 | }); 103 | 104 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1-test.log"], rotation: 1, write: 1 })); 105 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 106 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 107 | }); 108 | 109 | describe("monthly rotation", () => { 110 | const events = test({ files: { "test.log": "test\n" }, options: { interval: "2M", size: "10B" } }, rfs => { 111 | let cnt = 0; 112 | rfs.maxTimeout = 200; 113 | rfs.now = (): Date => { 114 | cnt++; 115 | if(cnt === 1 || cnt === 2) return new Date(1976, 0, 23, 0, 0, 0, 0); 116 | if(cnt === 3) return new Date(1976, 1, 1, 0, 0, 0, 0); 117 | if(cnt === 4) return new Date(1976, 1, 29, 23, 59, 59, 950); 118 | if(cnt === 5 || cnt === 6) return new Date(1976, 2, 1, 0, 0, 0, 0); 119 | if(cnt === 7 || cnt === 8) return new Date(1976, 2, 10, 0, 0, 0, 0); 120 | if(cnt === 9) return new Date(1976, 3, 30, 23, 59, 59, 950); 121 | return new Date(1976, 4, 1, 0, 0, 0, 0); 122 | }; 123 | 124 | rfs.write("test\n"); 125 | rfs.once("rotated", (): void => { 126 | rfs.write("test\n"); 127 | rfs.write("test\n"); 128 | rfs.once("rotated", (): void => { 129 | rfs.write("test\n"); 130 | rfs.once("rotated", (): void => { 131 | rfs.end("test\n"); 132 | }); 133 | }); 134 | }); 135 | }); 136 | 137 | it("events", () => 138 | deq(events, { close: 1, finish: 1, open: ["test.log", "test.log", "test.log", "test.log"], rotated: ["1-test.log", "2-test.log", "3-test.log"], rotation: 3, write: 3, writev: 1 })); 139 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 140 | it("rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\n")); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/07compression.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 3 | import { gunzipSync } from "zlib"; 4 | import { test } from "./helper"; 5 | 6 | describe("compression", () => { 7 | describe("external", () => { 8 | const events = test( 9 | { 10 | filename: (time: number | Date, index?: number) => (time ? `test.log/${index}` : "test.log/log"), 11 | options: { compress: true, size: "10B" } 12 | }, 13 | rfs => rfs.end("test\ntest\n") 14 | ); 15 | 16 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log/log", "test.log/log"], rotated: ["test.log/1"], rotation: 1, stderr: [""], stdout: [""], write: 1 })); 17 | it("file content", () => eq(readFileSync("test.log/log", "utf8"), "")); 18 | it("rotated file content", () => eq(gunzipSync(readFileSync("test.log/1")).toString(), "test\ntest\n")); 19 | }); 20 | 21 | describe("external command error", () => { 22 | const events = test( 23 | { 24 | filename: (time: number | Date, index?: number) => (time ? `test.log/${index}` : "test.log/log"), 25 | options: { compress: (source, dest) => `echo ${source} ; >&2 echo ${dest} ; exit 23`, size: "10B" } 26 | }, 27 | rfs => rfs.end("test\ntest\n") 28 | ); 29 | 30 | it("events", () => deq(events, { close: 1, error: [23], finish: 1, open: ["test.log/log"], rotation: 1, stderr: ["test.log/1\n"], stdout: ["test.log/log\n"], write: 1 })); 31 | it("file content", () => eq(readFileSync("test.log/log", "utf8"), "test\ntest\n")); 32 | }); 33 | 34 | describe("custom external", () => { 35 | const events = test( 36 | { 37 | filename: (time: number | Date, index?: number) => (time ? `test${index}.log` : "test.log"), 38 | options: { compress: (source: string, dest: string): string => `cat ${source} | gzip -c9 > ${dest}`, size: "10B" } 39 | }, 40 | rfs => rfs.end("test\ntest\n") 41 | ); 42 | 43 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["test1.log"], rotation: 1, stderr: [""], stdout: [""], write: 1 })); 44 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 45 | it("rotated file content", () => eq(gunzipSync(readFileSync("test1.log")).toString(), "test\ntest\n")); 46 | }); 47 | 48 | describe("generator", () => { 49 | const events = test({ filename: "test.log", options: { compress: "gzip", mode: 0o660, size: "10B" } }, rfs => { 50 | rfs.now = (): Date => new Date(2015, 2, 29, 1, 29, 23, 123); 51 | rfs.end("test\ntest\n"); 52 | }); 53 | 54 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["20150329-0129-01-test.log.gz"], rotation: 1, write: 1 })); 55 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 56 | it("rotated file content", () => eq(gunzipSync(readFileSync("20150329-0129-01-test.log.gz")).toString(), "test\ntest\n")); 57 | }); 58 | 59 | describe("internal", () => { 60 | const events = test({ filename: (time: number | Date, index?: number): string => (time ? "log/log/test.gz" + index : "test.log"), options: { compress: "gzip", mode: 0o660, size: "10B" } }, rfs => 61 | rfs.end("test\ntest\n") 62 | ); 63 | 64 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["log/log/test.gz1"], rotation: 1, write: 1 })); 65 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 66 | it("rotated file content", () => eq(gunzipSync(readFileSync("log/log/test.gz1")).toString(), "test\ntest\n")); 67 | }); 68 | 69 | describe("external error", () => { 70 | const events = test({ options: { compress: true, size: "10B" } }, rfs => { 71 | rfs.exec = (command: string, callback: (error: Error) => void) => callback(new Error("test")); 72 | rfs.write("test\ntest\n"); 73 | }); 74 | 75 | it("events", () => deq(events, { close: 1, error: ["test"], finish: 1, open: ["test.log"], rotation: 1, stderr: [undefined], stdout: [undefined], write: 1 })); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/08classical.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 2 | import { readFileSync } from "fs"; 3 | import { gunzipSync } from "zlib"; 4 | import { sep } from "path"; 5 | import { test } from "./helper"; 6 | 7 | describe("classical", function() { 8 | describe("classical generator", () => { 9 | const events = test({ filename: "test.log", options: { path: "log", rotate: 2, size: "10B" } }, rfs => { 10 | rfs.write("test\ntest\n"); 11 | rfs.end("test\n"); 12 | }); 13 | 14 | it("events", () => deq(events, { close: 1, finish: 1, open: ["log/test.log", "log/test.log"], rotated: ["log/test.log.1"], rotation: 1, write: 2 })); 15 | it("file content", () => eq(readFileSync("log/test.log", "utf8"), "test\n")); 16 | it("rotated file content", () => eq(readFileSync("log/test.log.1", "utf8"), "test\ntest\n")); 17 | }); 18 | 19 | describe("classical generator (compress)", () => { 20 | const events = test({ filename: "test.log", options: { compress: "gzip", path: "log", rotate: 2, size: "10B" } }, rfs => { 21 | rfs.write("test\ntest\n"); 22 | rfs.end("test\n"); 23 | }); 24 | 25 | it("events", () => deq(events, { close: 1, finish: 1, open: ["log/test.log", "log/test.log"], rotated: ["log/test.log.1.gz"], rotation: 1, write: 2 })); 26 | it("file content", () => eq(readFileSync("log/test.log", "utf8"), "test\n")); 27 | it("rotated file content", () => eq(gunzipSync(readFileSync("log/test.log.1.gz")).toString(), "test\ntest\n")); 28 | }); 29 | 30 | describe("initial rotation with interval", () => { 31 | const events = test( 32 | { filename: (index?: number | Date): string => (index ? `${index}.test.log` : "test.log"), files: { "test.log": "test\ntest\n" }, options: { interval: "1d", rotate: 2, size: "10B" } }, 33 | rfs => { 34 | rfs.write("test\n"); 35 | rfs.end("test\n"); 36 | } 37 | ); 38 | 39 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["1.test.log", "2.test.log"], rotation: 2, write: 2 })); 40 | it("file content", () => eq(readFileSync("test.log", "utf8"), "")); 41 | it("first rotated file content", () => eq(readFileSync("1.test.log", "utf8"), "test\ntest\n")); 42 | it("second rotated file content", () => eq(readFileSync("2.test.log", "utf8"), "test\ntest\n")); 43 | }); 44 | 45 | describe("rotation overflow", () => { 46 | const events = test({ filename: (index?: number | Date): string => (index ? `${index}.test.log` : "test.log"), options: { rotate: 2, size: "10B" } }, rfs => { 47 | rfs.write("test\ntest\ntest\ntest\n"); 48 | rfs.write("test\ntest\ntest\n"); 49 | rfs.write("test\ntest\n"); 50 | rfs.end("test\n"); 51 | }); 52 | 53 | it("events", () => 54 | deq(events, { close: 1, finish: 1, open: ["test.log", "test.log", "test.log", "test.log"], rotated: ["1.test.log", "2.test.log", "2.test.log"], rotation: 3, write: 1, writev: 1 })); 55 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 56 | it("first rotated file content", () => eq(readFileSync("1.test.log", "utf8"), "test\ntest\n")); 57 | it("second rotated file content", () => eq(readFileSync("2.test.log", "utf8"), "test\ntest\ntest\n")); 58 | }); 59 | 60 | describe("missing directory", () => { 61 | const events = test({ filename: (index?: number | Date): string => (index ? `log${sep}${index}.test.log` : "test.log"), options: { rotate: 2, size: "10B" } }, rfs => { 62 | rfs.write("test\ntest\n"); 63 | rfs.end("test\n"); 64 | }); 65 | 66 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log"], rotated: ["log/1.test.log"], rotation: 1, write: 2 })); 67 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 68 | it("rotated file content", () => eq(readFileSync("log/1.test.log", "utf8"), "test\ntest\n")); 69 | }); 70 | 71 | describe("compression", () => { 72 | const events = test({ filename: (index?: number | Date): string => (index ? `${index}.test.log` : "test.log"), options: { compress: "gzip", rotate: 2, size: "10B" } }, rfs => { 73 | rfs.write("test\ntest\ntest\n"); 74 | rfs.write("test\ntest\n"); 75 | rfs.end("test\n"); 76 | }); 77 | 78 | it("events", () => deq(events, { close: 1, finish: 1, open: ["test.log", "test.log", "test.log"], rotated: ["1.test.log", "2.test.log"], rotation: 2, write: 1, writev: 1 })); 79 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 80 | it("first rotated file content", () => eq(gunzipSync(readFileSync("1.test.log")).toString(), "test\ntest\n")); 81 | it("second rotated file content", () => eq(gunzipSync(readFileSync("2.test.log")).toString(), "test\ntest\ntest\n")); 82 | }); 83 | 84 | describe("rotating on directory which is file", () => { 85 | const events = test({ filename: (index?: number | Date): string => (index ? "txt/test.log" : "test.log"), files: { txt: "test\n" }, options: { rotate: 2, size: "10B" } }, rfs => { 86 | rfs.write("test\ntest\n"); 87 | rfs.end("test\n"); 88 | }); 89 | 90 | it("events", () => deq(events, { close: 1, error: ["ENOTDIR"], finish: 1, open: ["test.log"], rotation: 1, write: 1 })); 91 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\ntest\n")); 92 | }); 93 | 94 | describe("wrong name generator", () => { 95 | const events = test( 96 | { 97 | filename: (index?: number | Date): string => { 98 | if(index) throw new Error("test"); 99 | return "test.log"; 100 | }, 101 | options: { rotate: 2, size: "10B" } 102 | }, 103 | rfs => { 104 | rfs.write("test\ntest\n"); 105 | rfs.end("test\n"); 106 | } 107 | ); 108 | 109 | it("events", () => deq(events, { close: 1, error: ["test"], finish: 1, open: ["test.log"], rotation: 1, write: 1 })); 110 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\ntest\n")); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/09history.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq, strictEqual as eq } from "assert"; 2 | import { readFileSync } from "fs"; 3 | import { test } from "./helper"; 4 | 5 | describe("history", () => { 6 | describe("maxFiles", () => { 7 | const events = test({ files: { "log/files.txt": "test\nnone\n" }, options: { history: "files.txt", maxFiles: 3, path: "log", size: "10B" } }, rfs => { 8 | rfs.write("test\ntest\n"); 9 | rfs.write("test\ntest\ntest\n"); 10 | rfs.write("test\ntest\ntest\ntest\n"); 11 | rfs.write("test\ntest\ntest\ntest\ntest\n"); 12 | rfs.write("test\ntest\ntest\ntest\ntest\ntest\n"); 13 | rfs.end("test\n"); 14 | }); 15 | 16 | it("events", () => 17 | deq(events, { 18 | close: 1, 19 | finish: 1, 20 | history: 5, 21 | open: ["log/test.log", "log/test.log", "log/test.log", "log/test.log", "log/test.log", "log/test.log"], 22 | removedN: ["log/1-test.log", "log/2-test.log"], 23 | rotated: ["log/1-test.log", "log/2-test.log", "log/3-test.log", "log/4-test.log", "log/1-test.log"], 24 | rotation: 5, 25 | warning: ["File 'test' contained in history is not a regular file"], 26 | write: 1, 27 | writev: 1 28 | })); 29 | it("file content", () => eq(readFileSync("log/test.log", "utf8"), "test\n")); 30 | it("first rotated file content", () => eq(readFileSync("log/3-test.log", "utf8"), "test\ntest\ntest\ntest\n")); 31 | it("second rotated file content", () => eq(readFileSync("log/4-test.log", "utf8"), "test\ntest\ntest\ntest\ntest\n")); 32 | it("third rotated file content", () => eq(readFileSync("log/1-test.log", "utf8"), "test\ntest\ntest\ntest\ntest\ntest\n")); 33 | it("history file content", () => eq(readFileSync("log/files.txt", "utf8"), "log/3-test.log\nlog/4-test.log\nlog/1-test.log\n")); 34 | }); 35 | 36 | describe("maxFiles 1", () => { 37 | const events = test({ files: { "log/files.txt": "test\nnone\n" }, options: { history: "files.txt", maxFiles: 1, path: "log", size: "10B" } }, rfs => { 38 | rfs.write("test\ntest\n"); 39 | rfs.write("test\ntest\ntest\n"); 40 | rfs.write("test\ntest\ntest\ntest\n"); 41 | rfs.write("test\ntest\ntest\ntest\ntest\n"); 42 | rfs.write("test\ntest\ntest\ntest\ntest\ntest\n"); 43 | rfs.end("test\n"); 44 | }); 45 | 46 | it("events", () => 47 | deq(events, { 48 | close: 1, 49 | finish: 1, 50 | history: 5, 51 | open: ["log/test.log", "log/test.log", "log/test.log", "log/test.log", "log/test.log", "log/test.log"], 52 | removedN: ["log/1-test.log", "log/2-test.log", "log/1-test.log", "log/2-test.log"], 53 | rotated: ["log/1-test.log", "log/2-test.log", "log/1-test.log", "log/2-test.log", "log/1-test.log"], 54 | rotation: 5, 55 | warning: ["File 'test' contained in history is not a regular file"], 56 | write: 1, 57 | writev: 1 58 | })); 59 | it("file content", () => eq(readFileSync("log/test.log", "utf8"), "test\n")); 60 | it("first rotated file content", () => eq(readFileSync("log/1-test.log", "utf8"), "test\ntest\ntest\ntest\ntest\ntest\n")); 61 | it("history file content", () => eq(readFileSync("log/files.txt", "utf8"), "log/1-test.log\n")); 62 | }); 63 | 64 | describe("maxSize", () => { 65 | const events = test({ options: { maxSize: "60B", size: "10B" } }, rfs => { 66 | rfs.write("test\ntest\n"); 67 | rfs.write("test\ntest\ntest\n"); 68 | rfs.write("test\ntest\ntest\ntest\n"); 69 | rfs.write("test\ntest\ntest\ntest\ntest\n"); 70 | rfs.write("test\ntest\ntest\ntest\ntest\ntest\n"); 71 | rfs.end("test\n"); 72 | }); 73 | 74 | it("events", () => 75 | deq(events, { 76 | close: 1, 77 | finish: 1, 78 | history: 5, 79 | open: ["test.log", "test.log", "test.log", "test.log", "test.log", "test.log"], 80 | removedS: ["1-test.log", "2-test.log", "3-test.log"], 81 | rotated: ["1-test.log", "2-test.log", "3-test.log", "4-test.log", "1-test.log"], 82 | rotation: 5, 83 | write: 1, 84 | writev: 1 85 | })); 86 | it("file content", () => eq(readFileSync("test.log", "utf8"), "test\n")); 87 | it("first rotated file content", () => eq(readFileSync("4-test.log", "utf8"), "test\ntest\ntest\ntest\ntest\n")); 88 | it("second rotated file content", () => eq(readFileSync("1-test.log", "utf8"), "test\ntest\ntest\ntest\ntest\ntest\n")); 89 | }); 90 | 91 | describe("error reading history file", () => { 92 | const events = test({ options: { maxSize: "60B", size: "10B" } }, rfs => { 93 | rfs.fsReadFile = async (path: string, encoding: string) => { 94 | throw new Error(`test ${path} ${encoding}`); 95 | }; 96 | rfs.write("test\ntest\n"); 97 | }); 98 | 99 | it("events", () => deq(events, { close: 1, error: ["test test.log.txt utf8"], finish: 1, open: ["test.log"], rotation: 1, write: 1 })); 100 | }); 101 | 102 | describe("error checking rotated file", () => { 103 | const events = test({ options: { maxSize: "60B", size: "10B" } }, rfs => { 104 | rfs.fsStat = async (path: string) => { 105 | throw new Error(`test ${path}`); 106 | }; 107 | rfs.write("test\ntest\n"); 108 | }); 109 | 110 | it("events", () => deq(events, { close: 1, error: ["test 1-test.log"], finish: 1, open: ["test.log"], rotation: 1, write: 1 })); 111 | }); 112 | 113 | describe("immutable", () => { 114 | let min = 0; 115 | const events = test({ filename: "test.log", options: { immutable: true, interval: "1d", maxFiles: 2, size: "10B" } }, rfs => { 116 | rfs.now = (): Date => new Date(2015, 0, 23, 1, ++min, 23, 123); 117 | rfs.write("test\ntest\n"); 118 | rfs.write("test\ntest\ntest\n"); 119 | rfs.write("test\ntest\ntest\ntest\n"); 120 | rfs.write("test\ntest\ntest\ntest\ntest\n"); 121 | rfs.end("test\n"); 122 | }); 123 | 124 | it("events", () => 125 | deq(events, { 126 | close: 1, 127 | finish: 1, 128 | history: 4, 129 | open: ["20150123-0101-01-test.log", "20150123-0105-01-test.log", "20150123-0109-01-test.log", "20150123-0113-01-test.log", "20150123-0117-01-test.log"], 130 | removedN: ["20150123-0101-01-test.log", "20150123-0105-01-test.log"], 131 | rotated: ["20150123-0101-01-test.log", "20150123-0105-01-test.log", "20150123-0109-01-test.log", "20150123-0113-01-test.log"], 132 | rotation: 4, 133 | write: 1, 134 | writev: 1 135 | })); 136 | it("file content", () => eq(readFileSync("20150123-0117-01-test.log", "utf8"), "test\n")); 137 | it("first rotated file content", () => eq(readFileSync("20150123-0109-01-test.log", "utf8"), "test\ntest\ntest\ntest\n")); 138 | it("second rotated file content", () => eq(readFileSync("20150123-0113-01-test.log", "utf8"), "test\ntest\ntest\ntest\ntest\n")); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test/99clean.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual as deq } from "assert"; 2 | import { test } from "./helper"; 3 | 4 | describe("clean", () => { 5 | const events = test({ filename: "test" }, rfs => rfs.end("test")); 6 | 7 | it("clean", () => deq(events, { close: 1, error: ["Can't write on: test (it is not a file)"], finish: 1, write: 1 })); 8 | }); 9 | -------------------------------------------------------------------------------- /test/helper.ts: -------------------------------------------------------------------------------- 1 | import { Generator, Options, createStream } from ".."; 2 | import { chmod, mkdir, readdir, rm, rmdir, stat, unlink, utimes, writeFile } from "fs/promises"; 3 | 4 | type FilesOpt = { [key: string]: string | { content: string; date?: Date; mode?: number } }; 5 | 6 | async function fillFiles(files?: FilesOpt): Promise { 7 | if(! files) return; 8 | 9 | for(const file in files) { 10 | const value = files[file]; 11 | const content = typeof value === "string" ? value : value.content; 12 | const tokens = file.split("/"); 13 | 14 | if(tokens.length > 1) await mkdir(tokens.slice(0, -1).join("/"), { recursive: true }); 15 | await writeFile(file, content); 16 | 17 | if(typeof value !== "string") { 18 | const { date, mode } = value; 19 | 20 | if(date) await utimes(file, date, date); 21 | if(mode) await chmod(file, mode); 22 | } 23 | } 24 | } 25 | 26 | async function recursiveRemove(): Promise { 27 | const files = await readdir("."); 28 | const versions = process.version 29 | .replace("v", "") 30 | .split(".") 31 | .map(_ => parseInt(_, 10)); 32 | const ge14_14 = versions[0] > 14 || (versions[0] === 14 && versions[1] >= 14); 33 | 34 | for(const file of files) { 35 | if(file.match(/(gz|log|tmp|txt)$/)) { 36 | if(ge14_14) await rm(file, { recursive: true }); 37 | else { 38 | const stats = await stat(file); 39 | 40 | if(stats.isDirectory()) await rmdir(file, { recursive: true }); 41 | else await unlink(file); 42 | } 43 | } 44 | } 45 | } 46 | 47 | interface testOpt { 48 | filename?: string | Generator; 49 | files?: FilesOpt; 50 | options?: Options; 51 | } 52 | 53 | interface ErrorWithCode { 54 | code: string; 55 | } 56 | 57 | function isErrorWithCode(error: any): error is ErrorWithCode { 58 | return "code" in error; 59 | } 60 | 61 | export function test(opt: testOpt, test: (rfs: any) => void): any { 62 | const { filename, files, options } = opt; 63 | const events: any = {}; 64 | 65 | beforeAll(function(done): void { 66 | let did: boolean; 67 | 68 | const generator = filename ? filename : (time: number | Date, index?: number): string => (time ? index + "-test.log" : "test.log"); 69 | const timeOut = setTimeout(() => { 70 | did = events.timedOut = true; 71 | done(); 72 | }, 5000); 73 | 74 | const end = (): void => { 75 | clearTimeout(timeOut); 76 | if(did) return; 77 | did = true; 78 | done(); 79 | }; 80 | 81 | const create = (): void => { 82 | const rfs = createStream(generator, options); 83 | const inc: (name: string) => any = name => { 84 | if(! events[name]) events[name] = 0; 85 | events[name]++; 86 | }; 87 | const push: (name: string, what: string) => any = (name, what) => { 88 | if(! events[name]) events[name] = []; 89 | events[name].push(what); 90 | }; 91 | 92 | rfs.on("close", () => inc("close")); 93 | rfs.on("close", end); 94 | rfs.on("error", error => push("error", isErrorWithCode(error) ? error.code : error.message)); 95 | rfs.on("external", (stdout, stderr) => { 96 | push("stdout", stdout); 97 | push("stderr", stderr); 98 | }); 99 | rfs.on("finish", () => inc("finish")); 100 | rfs.on("history", () => inc("history")); 101 | rfs.on("open", filename => push("open", filename)); 102 | rfs.on("removed", (filename, number) => push("removed" + (number ? "N" : "S"), filename)); 103 | rfs.on("rotated", filename => push("rotated", filename)); 104 | rfs.on("rotation", () => inc("rotation")); 105 | rfs.on("warning", error => push("warning", error.message)); 106 | 107 | const oldW = rfs._write; 108 | const oldV = rfs._writev; 109 | 110 | rfs._write = (chunk: Buffer, encoding: string, callback: (error?: Error) => void): void => { 111 | inc("write"); 112 | oldW.call(rfs, chunk, encoding, callback); 113 | }; 114 | 115 | rfs._writev = (chunks: any, callback: (error?: Error) => void): void => { 116 | inc("writev"); 117 | oldV.call(rfs, chunks, callback); 118 | }; 119 | 120 | test(rfs); 121 | }; 122 | 123 | (async () => { 124 | await recursiveRemove(); 125 | await fillFiles(files); 126 | create(); 127 | })(); 128 | }); 129 | 130 | return events; 131 | } 132 | -------------------------------------------------------------------------------- /testSequencer.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const Sequencer = require("@jest/test-sequencer").default; 3 | 4 | class CustomSequencer extends Sequencer { 5 | shard(tests, { shardIndex, shardCount }) { 6 | const shardSize = Math.ceil(tests.length / shardCount); 7 | const shardStart = shardSize * (shardIndex - 1); 8 | const shardEnd = shardSize * shardIndex; 9 | 10 | return [...tests].sort((a, b) => (a.path > b.path ? 1 : -1)).slice(shardStart, shardEnd); 11 | } 12 | 13 | sort(tests) { 14 | const copyTests = Array.from(tests); 15 | 16 | return copyTests.sort((testA, testB) => (testA.path > testB.path ? 1 : -1)); 17 | } 18 | } 19 | 20 | module.exports = CustomSequencer; 21 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "outDir": "dist/cjs", 5 | "target": "es2019" 6 | }, 7 | "extends": "./tsconfig.json" 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "outDir": "dist/esm", 5 | "target": "esnext" 6 | }, 7 | "extends": "./tsconfig.json" 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "forceConsistentCasingInFileNames": true, 4 | "lib": ["es2019", "es2020.bigint", "es2020.string", "es2020.symbol.wellknown"], 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "skipLibCheck": true, 8 | "target": "es2020" 9 | }, 10 | "include": ["./index.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "emitDeclarationOnly": true, 5 | "outDir": "dist/types" 6 | }, 7 | "extends": "./tsconfig.json" 8 | } 9 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "fs/promises"; 2 | 3 | const common: string[] = ["*gz", "*log", "*tmp", "*txt", ".gitignore", ".npmignore", "coverage", "node_modules", ""]; 4 | const git: string[] = ["dist"]; 5 | const npm: string[] = [".*", "CHANGELOG.md", "index.ts", "jest.config.cjs", "test", "testSequencer.cjs", "tsconfig.*", "utils.ts"]; 6 | 7 | if(process.argv[2] === "ignore") { 8 | (async () => { 9 | await writeFile(".gitignore", git.concat(common).join("\n")); 10 | await writeFile(".npmignore", npm.concat(common).join("\n")); 11 | })(); 12 | } 13 | --------------------------------------------------------------------------------