diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a19e8a..648d2af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules dist -coverage \ No newline at end of file +coverage +log +*.tsbuildinfo \ No newline at end of file diff --git a/README.md b/README.md index 52d3cd3..6db0242 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,43 @@ new App() .listen(3000) ``` +To Log a level, use the enum `LogLevel` + +```ts +import { App } from '@tinyhttp/app' +import { logger, LogLevel } from '@tinyhttp/logger' + +new App() + .use( + logger({ + methods: ['GET', 'POST'], + timestamp: { format: 'HH:mm:ss' }, + output: { callback: console.log, color: false, level: LogLevel.warn } + }) + ) + .get('/', (req, res) => res.send('Hello world')) + .listen(3000) +``` + +This also includes a simple file logger. To stream to a file, simply supply the filename in the options. Supported file names innclude +`./file.log` or `./log/tiny.log` + +```ts +import { App } from '@tinyhttp/app' +import { logger } from '@tinyhttp/logger' + +new App() + .use( + logger({ + methods: ['GET', 'POST'], + timestamp: { format: 'HH:mm:ss' }, + output: { callback: console.log, color: false, filename: './log/tiny.log' } + }) + ) + .get('/', (req, res) => res.send('Hello world')) + .listen(3000) +``` + ## Alternatives - [Pino](https://getpino.io) - super fast, all natural json logger. diff --git a/package.json b/package.json index 43c9823..135e02c 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "prettier": "^2.8.8", "rollup": "^3.23.0", "supertest-fetch": "^1.5.0", + "tslib": "^2.5.2", "tsm": "^2.3.0", "typescript": "^5.0.4", "uvu": "^0.5.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bef04e7..6087f81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,7 +20,7 @@ devDependencies: version: 17.6.3 '@rollup/plugin-typescript': specifier: ^11.1.1 - version: 11.1.1(rollup@3.23.0)(typescript@5.0.4) + version: 11.1.1(rollup@3.23.0)(tslib@2.5.2)(typescript@5.0.4) '@tinyhttp/app': specifier: 2.1.0 version: 2.1.0 @@ -60,6 +60,9 @@ devDependencies: supertest-fetch: specifier: ^1.5.0 version: 1.5.0 + tslib: + specifier: ^2.5.2 + version: 2.5.2 tsm: specifier: ^2.3.0 version: 2.3.0 @@ -431,7 +434,7 @@ packages: fastq: 1.15.0 dev: true - /@rollup/plugin-typescript@11.1.1(rollup@3.23.0)(typescript@5.0.4): + /@rollup/plugin-typescript@11.1.1(rollup@3.23.0)(tslib@2.5.2)(typescript@5.0.4): resolution: {integrity: sha512-Ioir+x5Bejv72Lx2Zbz3/qGg7tvGbxQZALCLoJaGrkNXak/19+vKgKYJYM3i/fJxvsb23I9FuFQ8CUBEfsmBRg==} engines: {node: '>=14.0.0'} peerDependencies: @@ -447,6 +450,7 @@ packages: '@rollup/pluginutils': 5.0.2(rollup@3.23.0) resolve: 1.22.2 rollup: 3.23.0 + tslib: 2.5.2 typescript: 5.0.4 dev: true @@ -2887,6 +2891,10 @@ packages: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} dev: true + /tslib@2.5.2: + resolution: {integrity: sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==} + dev: true + /tsm@2.3.0: resolution: {integrity: sha512-++0HFnmmR+gMpDtKTnW3XJ4yv9kVGi20n+NfyQWB9qwJvTaIWY9kBmzek2YUQK5APTQ/1DTrXmm4QtFPmW9Rzw==} engines: {node: '>=12'} diff --git a/src/filelogger.ts b/src/filelogger.ts new file mode 100644 index 0000000..ceed4ad --- /dev/null +++ b/src/filelogger.ts @@ -0,0 +1,67 @@ +import { accessSync, writeFileSync, createWriteStream, WriteStream, mkdirSync } from 'fs' +import { dirname as directoryname } from 'path' + +export class FileLogger { + readonly #filename: string + readonly #dirname: string + private writableStream: WriteStream + constructor(filename: string) { + this.#dirname = directoryname(filename) + this.#filename = filename + this.#_stat() + this.#_createWritableStream() + this.#_endStream() + } + + #fsAccess(filename: string, mode?: number) { + try { + accessSync(filename, mode) + return true + } catch (error) { + return false + } + } + + #_stat() { + //check if file exists + if (!this.#fsAccess(this.#filename)) { + // check if directory exists + if (!this.#fsAccess(this.#dirname)) { + // create the directory + mkdirSync(this.#dirname, { recursive: true }) + } + // create the file and write an empty string to it + writeFileSync(this.#filename, '') + return + } + } + + #_createWritableStream() { + this.writableStream = createWriteStream(this.#filename, { flags: 'a' }) + } + + toFile(stringToLog: string) { + this.writableStream.write(stringToLog + '\n') + } + + #_endStream() { + process.on('exit', () => { + this.writableStream.close() + }) + + process.on('SIGTERM', () => { + this.writableStream.close() + process.exit(0) + }) + + process.on('SIGINT', () => { + this.writableStream.close() + process.exit(0) + }) + + process.on('uncaughtException', () => { + this.writableStream.close() + process.exit(1) + }) + } +} diff --git a/src/index.ts b/src/index.ts index f4e31a8..719787d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,23 @@ import { cyan, red, magenta, bold } from 'colorette' import statusEmoji from 'http-status-emojis' import dayjs from 'dayjs' import { METHODS, ServerResponse as Response, IncomingMessage as Request } from 'http' +import { FileLogger } from './filelogger' + +export enum LogLevel { + error = 'error', + warn = 'warn', + trace = 'trace', + info = 'info', + log = 'log' +} export interface LoggerOptions { methods?: string[] output?: { color: boolean + filename?: string callback: (string: string) => void + level?: LogLevel } timestamp?: boolean | { format?: string } emoji?: boolean @@ -24,12 +35,12 @@ const compileArgs = ( ) => { const { method } = req const { statusCode } = res - const url = req.originalUrl || req.url - const methods = options.methods ?? METHODS const timestamp = options.timestamp ?? false const emojiEnabled = options.emoji + const level = options.output && options.output.level ? options.output.level : null + if (level) args.push('[' + level.toUpperCase() + ']') if (methods.includes(method) && timestamp) { args.push( @@ -52,35 +63,42 @@ const compileArgs = ( export const logger = (options: LoggerOptions = {}) => { const methods = options.methods ?? METHODS - const output = options.output ?? { callback: console.log, color: true } - + const output = options.output ?? { callback: console.log, color: true, level: null } + let filelogger = null + if (options.output && options.output.filename) { + filelogger = new FileLogger(options.output.filename) + } return (req: Request, res: Response, next?: () => void) => { res.on('finish', () => { const args: (string | number)[] = [] - + // every time if (methods.includes(req.method)) { const s = res.statusCode.toString() - + let stringToLog = '' if (!output.color) { compileArgs(args, req, res, options) const m = args.join(' ') - output.callback(m) + stringToLog = m } else { switch (s[0]) { case '2': compileArgs(args, req, res, options, cyan(bold(s)), cyan(res.statusMessage)) - output.callback(args.join(' ')) + stringToLog = args.join(' ') break case '4': compileArgs(args, req, res, options, red(bold(s)), red(res.statusMessage)) - output.callback(args.join(' ')) + stringToLog = args.join(' ') break case '5': compileArgs(args, req, res, options, magenta(bold(s)), magenta(res.statusMessage)) - output.callback(args.join(' ')) + stringToLog = args.join(' ') break } } + output.callback(stringToLog) + if (filelogger) { + filelogger.toFile(stringToLog) + } } }) diff --git a/tests/index.test.ts b/tests/index.test.ts index 98de7ea..65cb33e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,9 +1,12 @@ import { Context, suite, uvu } from 'uvu' -import { logger } from '../src/index' +import { LogLevel, logger } from '../src/index' import { cyan, red, magenta, bold } from 'colorette' import { makeFetch } from 'supertest-fetch' import { App } from '@tinyhttp/app' import expect from 'expect' +import * as assert from 'uvu/assert' +import { promises, constants as fsConstants, readFileSync, unlinkSync, existsSync } from 'fs' +import { resolve } from 'path' function describe(name: string, fn: (it: uvu.Test) => void) { const s = suite(name) @@ -11,6 +14,13 @@ function describe(name: string, fn: (it: uvu.Test) => void) { s.run() } +function checkFileExists(file) { + return promises + .access(file) + .then(() => true) + .catch(() => false) +} + describe('Logger tests', (it) => { it('should use the timestamp format specified in the `format` property', () => { const originalConsoleLog = console.log @@ -35,7 +45,7 @@ describe('Logger tests', (it) => { const originalConsoleLog = console.log console.log = (log: string) => { - expect(log.split(' ')[0]).toMatch(/[0-9]{2}:[0-9]{2}:[0-9]{2}/) + expect(log).toMatch(/[0-9]{2}:[0-9]{2}:[0-9]{2}/) console.log = originalConsoleLog } @@ -50,6 +60,26 @@ describe('Logger tests', (it) => { server.close() }) }) + it('should check for levels when supplied', () => { + const level = LogLevel.log + const originalConsoleLog = console.log + + console.log = (log: string) => { + expect(log).toMatch(`[${level.toUpperCase()}] GET 404 Not Found /`) + console.log = originalConsoleLog + } + + const app = new App() + app.use(logger({ timestamp: false, output: { callback: console.log, color: false, level: level } })) + + const server = app.listen() + + makeFetch(server)('/') + .expect(404) + .then(() => { + server.close() + }) + }) it('should call a custom output function', () => { const customOutput = (log: string) => { @@ -67,6 +97,58 @@ describe('Logger tests', (it) => { server.close() }) }) + describe('Log file tests', (it) => { + it('should check if log file and directory is created', async (test) => { + const filename = './tests/tiny.log' + + const app = new App() + app.use( + logger({ + output: { + callback: console.log, + color: false, + filename: filename, + level: LogLevel.log + } + }) + ) + const server = app.listen() + await makeFetch(server)('/') + .expect(404) + .then(async () => { + assert.equal(await checkFileExists(filename), true) + }) + .finally(() => server.close()) + }) + it('should read log file and check if logs are written', async (test) => { + const filename = './logs/test1/tiny.log' + const level = LogLevel.warn + const app = new App() + app.use( + logger({ + output: { + callback: console.warn, + color: false, + filename: filename, + level: level + } + }) + ) + + const server = app.listen() + await makeFetch(server)('/') + .expect(404) + .then(async () => { + assert.equal(await checkFileExists(filename), true) + }) + .then(() => { + expect(readFileSync(filename).toString('utf-8').split('\n').slice(-2, -1)[0]).toMatch( + `[${level.toUpperCase()}] GET 404 Not Found /` + ) + }) + .finally(() => server.close()) + }) + }) describe('Color logs', (it) => { const createColorTest = (status: number, color: string) => { @@ -88,7 +170,9 @@ describe('Logger tests', (it) => { const server = app.listen() - await makeFetch(server)('/').expect(status) + await makeFetch(server)('/') + .expect(status) + .then(async () => await server.close()) } } diff --git a/tsconfig.json b/tsconfig.json index 29a9b5d..06585db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,11 @@ "preserveSymlinks": true, "allowSyntheticDefaultImports": true, "moduleResolution": "Node", - "baseUrl": "." + "baseUrl": ".", + "declarationMap": true, + "composite": true, + "sourceMap": true, + "alwaysStrict": true }, "include": ["./src/*.ts"] }