logixlysia 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ import pino from 'pino'
2
+ import type {
3
+ Logger,
4
+ LogLevel,
5
+ Options,
6
+ Pino,
7
+ RequestInfo,
8
+ StoreData
9
+ } from '../interfaces'
10
+ import { logToTransports } from '../output'
11
+ import { logToFile } from '../output/file'
12
+ import { formatLine } from './create-logger'
13
+ import { handleHttpError } from './handle-http-error'
14
+
15
+ export const createLogger = (options: Options = {}): Logger => {
16
+ const config = options.config
17
+
18
+ const pinoConfig = config?.pino
19
+ const { prettyPrint, ...pinoOptions } = pinoConfig ?? {}
20
+
21
+ const shouldPrettyPrint =
22
+ prettyPrint === true && pinoOptions.transport === undefined
23
+
24
+ const transport = shouldPrettyPrint
25
+ ? pino.transport({
26
+ target: 'pino-pretty',
27
+ options: {
28
+ colorize: process.stdout?.isTTY === true,
29
+ translateTime: config?.timestamp?.translateTime,
30
+ messageKey: pinoOptions.messageKey,
31
+ errorKey: pinoOptions.errorKey
32
+ }
33
+ })
34
+ : pinoOptions.transport
35
+
36
+ const pinoLogger: Pino = pino({
37
+ ...pinoOptions,
38
+ level: pinoOptions.level ?? 'info',
39
+ messageKey: pinoOptions.messageKey,
40
+ errorKey: pinoOptions.errorKey,
41
+ transport
42
+ })
43
+
44
+ const log = (
45
+ level: LogLevel,
46
+ request: RequestInfo,
47
+ data: Record<string, unknown>,
48
+ store: StoreData
49
+ ): void => {
50
+ logToTransports({ level, request, data, store, options })
51
+
52
+ const useTransportsOnly = config?.useTransportsOnly === true
53
+ const disableInternalLogger = config?.disableInternalLogger === true
54
+ const disableFileLogging = config?.disableFileLogging === true
55
+
56
+ if (!(useTransportsOnly || disableFileLogging)) {
57
+ const filePath = config?.logFilePath
58
+ if (filePath) {
59
+ logToFile({ filePath, level, request, data, store, options }).catch(
60
+ () => {
61
+ // Ignore errors
62
+ }
63
+ )
64
+ }
65
+ }
66
+
67
+ if (useTransportsOnly || disableInternalLogger) {
68
+ return
69
+ }
70
+
71
+ const message = formatLine({ level, request, data, store, options })
72
+
73
+ switch (level) {
74
+ case 'DEBUG': {
75
+ console.debug(message)
76
+ break
77
+ }
78
+ case 'INFO': {
79
+ console.info(message)
80
+ break
81
+ }
82
+ case 'WARNING': {
83
+ console.warn(message)
84
+ break
85
+ }
86
+ case 'ERROR': {
87
+ console.error(message)
88
+ break
89
+ }
90
+ default: {
91
+ console.log(message)
92
+ break
93
+ }
94
+ }
95
+ }
96
+
97
+ const logWithContext = (
98
+ level: LogLevel,
99
+ request: RequestInfo,
100
+ message: string,
101
+ context?: Record<string, unknown>
102
+ ): void => {
103
+ const store: StoreData = { beforeTime: process.hrtime.bigint() }
104
+ log(level, request, { message, context }, store)
105
+ }
106
+
107
+ return {
108
+ pino: pinoLogger,
109
+ log,
110
+ handleHttpError: (request, error, store) => {
111
+ handleHttpError(request, error, store, options)
112
+ },
113
+ debug: (request, message, context) => {
114
+ logWithContext('DEBUG', request, message, context)
115
+ },
116
+ info: (request, message, context) => {
117
+ logWithContext('INFO', request, message, context)
118
+ },
119
+ warn: (request, message, context) => {
120
+ logWithContext('WARNING', request, message, context)
121
+ },
122
+ error: (request, message, context) => {
123
+ logWithContext('ERROR', request, message, context)
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,85 @@
1
+ import { appendFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import type { LogLevel, Options, RequestInfo, StoreData } from '../interfaces'
4
+ import { ensureDir } from './fs'
5
+ import { performRotation, shouldRotate } from './rotation-manager'
6
+
7
+ type LogToFileInput = {
8
+ filePath: string
9
+ level: LogLevel
10
+ request: RequestInfo
11
+ data: Record<string, unknown>
12
+ store: StoreData
13
+ options: Options
14
+ }
15
+
16
+ export const logToFile = async (
17
+ ...args:
18
+ | [LogToFileInput]
19
+ | [
20
+ string,
21
+ LogLevel,
22
+ RequestInfo,
23
+ Record<string, unknown>,
24
+ StoreData,
25
+ Options
26
+ ]
27
+ ): Promise<void> => {
28
+ const input: LogToFileInput =
29
+ typeof args[0] === 'string'
30
+ ? (() => {
31
+ const [
32
+ filePathArg,
33
+ levelArg,
34
+ requestArg,
35
+ dataArg,
36
+ storeArg,
37
+ optionsArg
38
+ ] = args as [
39
+ string,
40
+ LogLevel,
41
+ RequestInfo,
42
+ Record<string, unknown>,
43
+ StoreData,
44
+ Options
45
+ ]
46
+ return {
47
+ filePath: filePathArg,
48
+ level: levelArg,
49
+ request: requestArg,
50
+ data: dataArg,
51
+ store: storeArg,
52
+ options: optionsArg
53
+ }
54
+ })()
55
+ : args[0]
56
+
57
+ const { filePath, level, request, data, store, options } = input
58
+ const config = options.config
59
+ const useTransportsOnly = config?.useTransportsOnly === true
60
+ const disableFileLogging = config?.disableFileLogging === true
61
+ if (useTransportsOnly || disableFileLogging) {
62
+ return
63
+ }
64
+
65
+ const message = typeof data.message === 'string' ? data.message : ''
66
+ const durationMs =
67
+ store.beforeTime === BigInt(0)
68
+ ? 0
69
+ : Number(process.hrtime.bigint() - store.beforeTime) / 1_000_000
70
+
71
+ const line = `${level} ${durationMs.toFixed(2)}ms ${request.method} ${new URL(request.url).pathname} ${message}\n`
72
+
73
+ await ensureDir(dirname(filePath))
74
+ await appendFile(filePath, line, { encoding: 'utf-8' })
75
+
76
+ const rotation = config?.logRotation
77
+ if (!rotation) {
78
+ return
79
+ }
80
+
81
+ const should = await shouldRotate(filePath, rotation)
82
+ if (should) {
83
+ await performRotation(filePath, rotation)
84
+ }
85
+ }
@@ -0,0 +1,5 @@
1
+ import { promises as fs } from 'node:fs'
2
+
3
+ export const ensureDir = async (dirPath: string): Promise<void> => {
4
+ await fs.mkdir(dirPath, { recursive: true })
5
+ }
@@ -0,0 +1,58 @@
1
+ import type { LogLevel, Options, RequestInfo, StoreData } from '../interfaces'
2
+
3
+ type LogToTransportsInput = {
4
+ level: LogLevel
5
+ request: RequestInfo
6
+ data: Record<string, unknown>
7
+ store: StoreData
8
+ options: Options
9
+ }
10
+
11
+ export const logToTransports = (
12
+ ...args:
13
+ | [LogToTransportsInput]
14
+ | [LogLevel, RequestInfo, Record<string, unknown>, StoreData, Options]
15
+ ): void => {
16
+ const input: LogToTransportsInput =
17
+ typeof args[0] === 'string'
18
+ ? {
19
+ level: args[0],
20
+ request: args[1],
21
+ data: args[2],
22
+ store: args[3],
23
+ options: args[4]
24
+ }
25
+ : args[0]
26
+
27
+ const { level, request, data, store, options } = input
28
+ const transports = options.config?.transports ?? []
29
+ if (transports.length === 0) {
30
+ return
31
+ }
32
+
33
+ const message = typeof data.message === 'string' ? data.message : ''
34
+ const meta: Record<string, unknown> = {
35
+ request: {
36
+ method: request.method,
37
+ url: request.url
38
+ },
39
+ ...data,
40
+ beforeTime: store.beforeTime
41
+ }
42
+
43
+ for (const transport of transports) {
44
+ try {
45
+ const result = transport.log(level, message, meta)
46
+ if (
47
+ result &&
48
+ typeof (result as { catch?: unknown }).catch === 'function'
49
+ ) {
50
+ ;(result as Promise<void>).catch(() => {
51
+ // Ignore errors
52
+ })
53
+ }
54
+ } catch {
55
+ // Transport failures must never crash application logging.
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,122 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { promisify } from 'node:util'
3
+ import { gzip } from 'node:zlib'
4
+ import type { LogRotationConfig } from '../interfaces'
5
+ import {
6
+ getRotatedFiles,
7
+ parseRetention,
8
+ parseSize,
9
+ shouldRotateBySize
10
+ } from '../utils/rotation'
11
+
12
+ const gzipAsync = promisify(gzip)
13
+
14
+ const pad2 = (value: number): string => String(value).padStart(2, '0')
15
+
16
+ export const getRotatedFileName = (filePath: string, date: Date): string => {
17
+ const yyyy = date.getFullYear()
18
+ const mm = pad2(date.getMonth() + 1)
19
+ const dd = pad2(date.getDate())
20
+ const HH = pad2(date.getHours())
21
+ const MM = pad2(date.getMinutes())
22
+ const ss = pad2(date.getSeconds())
23
+ return `${filePath}.${yyyy}-${mm}-${dd}-${HH}-${MM}-${ss}`
24
+ }
25
+
26
+ export const rotateFile = async (filePath: string): Promise<string> => {
27
+ try {
28
+ const stat = await fs.stat(filePath)
29
+ if (stat.size === 0) {
30
+ return ''
31
+ }
32
+ } catch {
33
+ return ''
34
+ }
35
+
36
+ const rotated = getRotatedFileName(filePath, new Date())
37
+ await fs.rename(filePath, rotated)
38
+ return rotated
39
+ }
40
+
41
+ export const compressFile = async (filePath: string): Promise<void> => {
42
+ const content = await fs.readFile(filePath)
43
+ const compressed = await gzipAsync(content)
44
+ await fs.writeFile(`${filePath}.gz`, compressed)
45
+ await fs.rm(filePath, { force: true })
46
+ }
47
+
48
+ export const shouldRotate = async (
49
+ filePath: string,
50
+ config: LogRotationConfig
51
+ ): Promise<boolean> => {
52
+ if (config.maxSize === undefined) {
53
+ return false
54
+ }
55
+ const maxSize = parseSize(config.maxSize)
56
+ return await shouldRotateBySize(filePath, maxSize)
57
+ }
58
+
59
+ const cleanupByCount = async (
60
+ filePath: string,
61
+ maxFiles: number
62
+ ): Promise<void> => {
63
+ const rotated = await getRotatedFiles(filePath)
64
+ if (rotated.length <= maxFiles) {
65
+ return
66
+ }
67
+
68
+ const stats = await Promise.all(
69
+ rotated.map(async p => ({ path: p, stat: await fs.stat(p) }))
70
+ )
71
+
72
+ stats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
73
+ const toDelete = stats.slice(maxFiles)
74
+ await Promise.all(toDelete.map(({ path }) => fs.rm(path, { force: true })))
75
+ }
76
+
77
+ const cleanupByTime = async (
78
+ filePath: string,
79
+ maxAgeMs: number
80
+ ): Promise<void> => {
81
+ const rotated = await getRotatedFiles(filePath)
82
+ if (rotated.length === 0) {
83
+ return
84
+ }
85
+
86
+ const now = Date.now()
87
+ const stats = await Promise.all(
88
+ rotated.map(async p => ({ path: p, stat: await fs.stat(p) }))
89
+ )
90
+
91
+ const toDelete = stats.filter(({ stat }) => now - stat.mtimeMs > maxAgeMs)
92
+ await Promise.all(toDelete.map(({ path }) => fs.rm(path, { force: true })))
93
+ }
94
+
95
+ export const performRotation = async (
96
+ filePath: string,
97
+ config: LogRotationConfig
98
+ ): Promise<void> => {
99
+ const rotated = await rotateFile(filePath)
100
+ if (!rotated) {
101
+ return
102
+ }
103
+
104
+ const shouldCompress = config.compress === true
105
+ if (shouldCompress) {
106
+ const algo = config.compression ?? 'gzip'
107
+ if (algo === 'gzip') {
108
+ await compressFile(rotated)
109
+ }
110
+ }
111
+
112
+ if (config.maxFiles !== undefined) {
113
+ const retention = parseRetention(config.maxFiles)
114
+ if (retention.type === 'count') {
115
+ await cleanupByCount(filePath, retention.value)
116
+ } else {
117
+ await cleanupByTime(filePath, retention.value)
118
+ }
119
+ }
120
+
121
+ // Optional interval-based rotation cleanup (create interval directories / naming) is not required by tests.
122
+ }
@@ -0,0 +1,15 @@
1
+ export const parseError = (error: unknown): string => {
2
+ let message = 'An error occurred'
3
+
4
+ if (error instanceof Error) {
5
+ message = error.message
6
+ } else if (error && typeof error === 'object' && 'message' in error) {
7
+ message = error.message as string
8
+ } else {
9
+ message = String(error)
10
+ }
11
+
12
+ console.error(`Parsing error: ${message}`)
13
+
14
+ return message
15
+ }
@@ -0,0 +1,91 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { basename, dirname } from 'node:path'
3
+
4
+ const SIZE_REGEX = /^(\d+(?:\.\d+)?)(k|kb|m|mb|g|gb)$/i
5
+ const INTERVAL_REGEX = /^(\d+)(h|d|w)$/i
6
+ const ROTATED_REGEX = /\.(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})(?:\.gz)?$/
7
+
8
+ export const parseSize = (value: number | string): number => {
9
+ if (typeof value === 'number') {
10
+ return value
11
+ }
12
+
13
+ const trimmed = value.trim()
14
+ const asNumber = Number(trimmed)
15
+ if (Number.isFinite(asNumber)) {
16
+ return asNumber
17
+ }
18
+
19
+ const match = trimmed.match(SIZE_REGEX)
20
+ if (!match) {
21
+ throw new Error(`Invalid size format: ${value}`)
22
+ }
23
+
24
+ const amount = Number(match[1])
25
+ const unit = match[2].toLowerCase()
26
+
27
+ let base = 1024
28
+ if (unit.startsWith('m')) {
29
+ base = 1024 * 1024
30
+ } else if (unit.startsWith('g')) {
31
+ base = 1024 * 1024 * 1024
32
+ }
33
+
34
+ return Math.floor(amount * base)
35
+ }
36
+
37
+ export const parseInterval = (value: string): number => {
38
+ const match = value.trim().match(INTERVAL_REGEX)
39
+ if (!match) {
40
+ throw new Error(`Invalid interval format: ${value}`)
41
+ }
42
+
43
+ const amount = Number(match[1])
44
+ const unit = match[2].toLowerCase()
45
+
46
+ let ms = 60 * 60 * 1000
47
+ if (unit === 'd') {
48
+ ms = 24 * 60 * 60 * 1000
49
+ } else if (unit === 'w') {
50
+ ms = 7 * 24 * 60 * 60 * 1000
51
+ }
52
+
53
+ return amount * ms
54
+ }
55
+
56
+ export const parseRetention = (
57
+ value: number | string
58
+ ): { type: 'count' | 'time'; value: number } => {
59
+ if (typeof value === 'number') {
60
+ return { type: 'count', value }
61
+ }
62
+ return { type: 'time', value: parseInterval(value) }
63
+ }
64
+
65
+ export const shouldRotateBySize = async (
66
+ filePath: string,
67
+ maxSizeBytes: number
68
+ ): Promise<boolean> => {
69
+ try {
70
+ const stat = await fs.stat(filePath)
71
+ return stat.size > maxSizeBytes
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ export const getRotatedFiles = async (filePath: string): Promise<string[]> => {
78
+ const dir = dirname(filePath)
79
+ const base = basename(filePath)
80
+
81
+ let entries: string[]
82
+ try {
83
+ entries = await fs.readdir(dir)
84
+ } catch {
85
+ return []
86
+ }
87
+
88
+ return entries
89
+ .filter(name => name.startsWith(`${base}.`) && ROTATED_REGEX.test(name))
90
+ .map(name => `${dir}/${name}`)
91
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2023 PunGrumpy
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.
package/README.md DELETED
@@ -1,57 +0,0 @@
1
- <div align="center">
2
- <h1><code>🦊</code> Logixlysia</h1>
3
- <strong>Logixlysia is a logging library for ElysiaJS</strong>
4
- <img src="./website/app/opengraph-image.png" alt="Logixlysia" width="100%" height="auto" />
5
- </div>
6
-
7
- ## `📩` Installation
8
-
9
- ```bash
10
- bun add logixlysia
11
- ```
12
-
13
- ## `📝` Usage
14
-
15
- ```ts
16
- import { Elysia } from 'elysia'
17
- import logixlysia from 'logixlysia'
18
-
19
- const app = new Elysia({
20
- name: 'Logixlysia Example'
21
- }).use(
22
- logixlysia({
23
- config: {
24
- showStartupMessage: true,
25
- startupMessageFormat: 'simple',
26
- timestamp: {
27
- translateTime: 'yyyy-mm-dd HH:MM:ss'
28
- },
29
- ip: true,
30
- logFilePath: './logs/example.log',
31
- logRotation: {
32
- maxSize: '10m',
33
- interval: '1d',
34
- maxFiles: '7d',
35
- compress: true
36
- },
37
- customLogFormat:
38
- '🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip} {epoch}',
39
- logFilter: {
40
- level: ['ERROR', 'WARNING'],
41
- status: [500, 404],
42
- method: 'GET'
43
- }
44
- }
45
- })
46
- )
47
-
48
- app.listen(3000)
49
- ```
50
-
51
- ## `📚` Documentation
52
-
53
- Check out the [website](https://logixlysia.vercel.app) for more detailed documentation and examples.
54
-
55
- ## `📄` License
56
-
57
- Licensed under the [MIT License](LICENSE).