@pori15/logixlysia 0.0.1

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,246 @@
1
+ import chalk from 'chalk'
2
+ import { getStatusCode } from '../helpers/status'
3
+ import type {
4
+ LogLevel,
5
+ Options,
6
+ Pino,
7
+ StoreData,
8
+
9
+ } from '../interfaces'
10
+
11
+ const pad2 = (value: number): string => String(value).padStart(2, '0')
12
+ const pad3 = (value: number): string => String(value).padStart(3, '0')
13
+
14
+ const shouldUseColors = (options: Options): boolean => {
15
+ const config = options.config
16
+ const enabledByConfig = config?.useColors ?? true
17
+
18
+ // Avoid ANSI sequences in non-interactive output (pipes, CI logs, files).
19
+ const isTty = typeof process !== 'undefined' && process.stdout?.isTTY === true
20
+ return enabledByConfig && isTty
21
+ }
22
+
23
+ const formatTimestamp = (date: Date, pattern?: string): string => {
24
+ if (!pattern) {
25
+ return date.toISOString()
26
+ }
27
+
28
+ const yyyy = String(date.getFullYear())
29
+ const mm = pad2(date.getMonth() + 1)
30
+ const dd = pad2(date.getDate())
31
+ const HH = pad2(date.getHours())
32
+ const MM = pad2(date.getMinutes())
33
+ const ss = pad2(date.getSeconds())
34
+ const SSS = pad3(date.getMilliseconds())
35
+
36
+ return pattern
37
+ .replaceAll('yyyy', yyyy)
38
+ .replaceAll('mm', mm)
39
+ .replaceAll('dd', dd)
40
+ .replaceAll('HH', HH)
41
+ .replaceAll('MM', MM)
42
+ .replaceAll('ss', ss)
43
+ .replaceAll('SSS', SSS)
44
+ }
45
+
46
+ const getIp = (request: Request): string => {
47
+ const forwarded = request.headers.get('x-forwarded-for')
48
+ if (forwarded) {
49
+ return forwarded.split(',')[0]?.trim() ?? ''
50
+ }
51
+ return request.headers.get('x-real-ip') ?? ''
52
+ }
53
+
54
+ const getColoredLevel = (level: LogLevel, useColors: boolean): string => {
55
+ if (!useColors) {
56
+ return level
57
+ }
58
+
59
+ if (level === 'ERROR') {
60
+ return chalk.bgRed.black(level)
61
+ }
62
+ if (level === 'WARNING') {
63
+ return chalk.bgYellow.black(level)
64
+ }
65
+ if (level === 'DEBUG') {
66
+ return chalk.bgBlue.black(level)
67
+ }
68
+
69
+ return chalk.bgGreen.black(level)
70
+ }
71
+
72
+ const getColoredMethod = (method: string, useColors: boolean): string => {
73
+ if (!useColors) {
74
+ return method
75
+ }
76
+
77
+ const upper = method.toUpperCase()
78
+ if (upper === 'GET') {
79
+ return chalk.green.bold(upper)
80
+ }
81
+ if (upper === 'POST') {
82
+ return chalk.blue.bold(upper)
83
+ }
84
+ if (upper === 'PUT') {
85
+ return chalk.yellow.bold(upper)
86
+ }
87
+ if (upper === 'PATCH') {
88
+ return chalk.yellowBright.bold(upper)
89
+ }
90
+ if (upper === 'DELETE') {
91
+ return chalk.red.bold(upper)
92
+ }
93
+ if (upper === 'OPTIONS') {
94
+ return chalk.cyan.bold(upper)
95
+ }
96
+ if (upper === 'HEAD') {
97
+ return chalk.greenBright.bold(upper)
98
+ }
99
+ if (upper === 'TRACE') {
100
+ return chalk.magenta.bold(upper)
101
+ }
102
+ if (upper === 'CONNECT') {
103
+ return chalk.cyanBright.bold(upper)
104
+ }
105
+
106
+ return chalk.white.bold(upper)
107
+ }
108
+
109
+ const getColoredStatus = (status: string, useColors: boolean): string => {
110
+ if (!useColors) {
111
+ return status
112
+ }
113
+
114
+ const numeric = Number.parseInt(status, 10)
115
+ if (!Number.isFinite(numeric)) {
116
+ return status
117
+ }
118
+
119
+ if (numeric >= 500) {
120
+ return chalk.red(status)
121
+ }
122
+ if (numeric >= 400) {
123
+ return chalk.yellow(status)
124
+ }
125
+ if (numeric >= 300) {
126
+ return chalk.cyan(status)
127
+ }
128
+ if (numeric >= 200) {
129
+ return chalk.green(status)
130
+ }
131
+ return chalk.gray(status)
132
+ }
133
+
134
+ const getColoredDuration = (duration: string, useColors: boolean): string => {
135
+ if (!useColors) {
136
+ return duration
137
+ }
138
+
139
+ return chalk.gray(duration)
140
+ }
141
+
142
+ const getColoredTimestamp = (timestamp: string, useColors: boolean): string => {
143
+ if (!useColors) {
144
+ return timestamp
145
+ }
146
+
147
+ return chalk.bgHex('#FFA500').black(timestamp)
148
+ }
149
+
150
+ const getColoredPathname = (pathname: string, useColors: boolean): string => {
151
+ if (!useColors) {
152
+ return pathname
153
+ }
154
+
155
+ return chalk.whiteBright(pathname)
156
+ }
157
+
158
+ const getContextString = (value: unknown): string => {
159
+ if (typeof value === 'object' && value !== null) {
160
+ return JSON.stringify(value)
161
+ }
162
+
163
+ return ''
164
+ }
165
+
166
+ export const formatLine = ({
167
+ level,
168
+ request,
169
+ data,
170
+ store,
171
+ options
172
+ }: {
173
+ level: LogLevel
174
+ request: Request
175
+ data: Record<string, unknown>
176
+ store: StoreData
177
+ options: Options
178
+ }): string => {
179
+ const config = options.config
180
+ const useColors = shouldUseColors(options)
181
+ const format =
182
+ config?.customLogFormat ??
183
+ '🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip} {context}'
184
+
185
+ const now = new Date()
186
+ const epoch = String(now.getTime())
187
+ const rawTimestamp = formatTimestamp(now, config?.timestamp?.translateTime)
188
+ const timestamp = getColoredTimestamp(rawTimestamp, useColors)
189
+
190
+ const message = typeof data.message === 'string' ? data.message : ''
191
+ const durationMs =
192
+ store.beforeTime === BigInt(0)
193
+ ? 0
194
+ : Number(process.hrtime.bigint() - store.beforeTime) / 1_000_000
195
+
196
+ const pathname = new URL(request.url).pathname
197
+ const statusValue = data.status
198
+ const statusCode =
199
+ statusValue === null || statusValue === undefined
200
+ ? 200
201
+ : getStatusCode(statusValue)
202
+ const status = String(statusCode)
203
+ const ip = config?.ip === true ? getIp(request) : ''
204
+ const ctxString = getContextString(data.context)
205
+ const coloredLevel = getColoredLevel(level, useColors)
206
+ const coloredMethod = getColoredMethod(request.method, useColors)
207
+ const coloredPathname = getColoredPathname(pathname, useColors)
208
+ const coloredStatus = getColoredStatus(status, useColors)
209
+ const coloredDuration = getColoredDuration(
210
+ `${durationMs.toFixed(2)}ms`,
211
+ useColors
212
+ )
213
+
214
+ return format
215
+ .replaceAll('{now}', timestamp)
216
+ .replaceAll('{epoch}', epoch)
217
+ .replaceAll('{level}', coloredLevel)
218
+ .replaceAll('{duration}', coloredDuration)
219
+ .replaceAll('{method}', coloredMethod)
220
+ .replaceAll('{pathname}', coloredPathname)
221
+ .replaceAll('{path}', coloredPathname)
222
+ .replaceAll('{status}', coloredStatus)
223
+ .replaceAll('{message}', message)
224
+ .replaceAll('{ip}', ip)
225
+ .replaceAll('{context}', ctxString)
226
+ }
227
+
228
+ export const logWithPino = (
229
+ logger: Pino,
230
+ level: LogLevel,
231
+ data: Record<string, unknown>
232
+ ): void => {
233
+ if (level === 'ERROR') {
234
+ logger.error(data)
235
+ return
236
+ }
237
+ if (level === 'WARNING') {
238
+ logger.warn(data)
239
+ return
240
+ }
241
+ if (level === 'DEBUG') {
242
+ logger.debug(data)
243
+ return
244
+ }
245
+ logger.info(data)
246
+ }
@@ -0,0 +1,62 @@
1
+ import { ProblemError } from '../Error/errors'
2
+ import type { LogLevel, Options, StoreData } from '../interfaces'
3
+ import { logToTransports } from '../output'
4
+ import { logToFile } from '../output/file'
5
+
6
+
7
+ export const handleHttpError = (
8
+ request: Request,
9
+ problem: ProblemError,
10
+ store: StoreData,
11
+ options: Options
12
+ ): void => {
13
+ const config = options.config
14
+ // 1. 准备日志数据:将 RFC 标准字段与日志元数据合并
15
+ const level: LogLevel = 'ERROR';
16
+ const rfcData = problem.toJSON();
17
+ const data = {
18
+ status: problem.status,
19
+ message: problem.detail || problem.title,
20
+ ...rfcData
21
+ };
22
+ // 2. 阶段:传输层 (Transports)
23
+ logToTransports({ level, request, data, store, options });
24
+ // 3. 阶段:持久化 (File Logging)
25
+ // 匹配你的接口:useTransportsOnly 和 disableFileLogging 直接在 config 下
26
+ if (!(config?.useTransportsOnly || config?.disableFileLogging)) {
27
+ const filePath = config?.logFilePath;
28
+ if (filePath) {
29
+ logToFile({ filePath, level, request, data, store, options }).catch(() => {});
30
+ }
31
+ }
32
+
33
+ // 4. 阶段:控制台输出 (Console/Internal)
34
+ if (config?.useTransportsOnly || config?.disableInternalLogger) return;
35
+
36
+
37
+ // 处理时间戳显示逻辑
38
+ let timestamp = '';
39
+ if (config?.timestamp) {
40
+ timestamp = `[${new Date().toISOString()}] `;
41
+ }
42
+
43
+ // 1. 安全提取 Method 和 Path
44
+ const method = typeof request === 'string' ? 'REQ' : request.method;
45
+ const urlString = typeof request === 'string' ? request : request.url;
46
+
47
+ let path: string;
48
+ try {
49
+ // 如果是完整 URL 则提取 pathname,如果是相对路径则直接使用
50
+ path = urlString.includes('://')
51
+ ? new URL(urlString).pathname
52
+ : urlString;
53
+ } catch {
54
+ path = urlString;
55
+ }
56
+
57
+ // 2. 语义化终端打印
58
+ // 现在的代码对 string 和 Request 类型都百分之百安全了
59
+ console.error(
60
+ `${timestamp}${level} ${method} ${path} ${problem.status} - ${problem.title}`
61
+ );
62
+ }
@@ -0,0 +1,125 @@
1
+ import pino from 'pino'
2
+ import type {
3
+ Logger,
4
+ LogLevel,
5
+ Options,
6
+ Pino,
7
+ StoreData
8
+ } from '../interfaces'
9
+ import { logToTransports } from '../output'
10
+ import { logToFile } from '../output/file'
11
+ import { formatLine } from './create-logger'
12
+ import { handleHttpError } from './handle-http-error'
13
+
14
+ export const createLogger = (options: Options = {}): Logger => {
15
+ const config = options.config
16
+
17
+ const pinoConfig = config?.pino
18
+ const { prettyPrint, ...pinoOptions } = pinoConfig ?? {}
19
+
20
+ const shouldPrettyPrint =
21
+ prettyPrint === true && pinoOptions.transport === undefined
22
+
23
+ const transport = shouldPrettyPrint
24
+ ? pino.transport({
25
+ target: 'pino-pretty',
26
+ options: {
27
+ colorize: process.stdout?.isTTY === true,
28
+ translateTime: config?.timestamp?.translateTime,
29
+ messageKey: pinoOptions.messageKey,
30
+ errorKey: pinoOptions.errorKey
31
+ }
32
+ })
33
+ : pinoOptions.transport
34
+
35
+ const pinoLogger: Pino = pino({
36
+ ...pinoOptions,
37
+ level: pinoOptions.level ?? 'info',
38
+ messageKey: pinoOptions.messageKey,
39
+ errorKey: pinoOptions.errorKey,
40
+ transport
41
+ })
42
+
43
+ const log = (
44
+ level: LogLevel,
45
+ request: Request,
46
+ data: Record<string, unknown>,
47
+ store: StoreData
48
+ ): void => {
49
+ logToTransports({ level, request, data, store, options })
50
+
51
+ const useTransportsOnly = config?.useTransportsOnly === true
52
+ const disableInternalLogger = config?.disableInternalLogger === true
53
+ const disableFileLogging = config?.disableFileLogging === true
54
+
55
+ if (!(useTransportsOnly || disableFileLogging)) {
56
+ const filePath = config?.logFilePath
57
+ if (filePath) {
58
+ logToFile({ filePath, level, request, data, store, options }).catch(
59
+ () => {
60
+ // Ignore errors
61
+ }
62
+ )
63
+ }
64
+ }
65
+
66
+ if (useTransportsOnly || disableInternalLogger) {
67
+ return
68
+ }
69
+
70
+ const message = formatLine({ level, request, data, store, options })
71
+
72
+ switch (level) {
73
+ case 'DEBUG': {
74
+ console.debug(message)
75
+ break
76
+ }
77
+ case 'INFO': {
78
+ console.info(message)
79
+ break
80
+ }
81
+ case 'WARNING': {
82
+ console.warn(message)
83
+ break
84
+ }
85
+ case 'ERROR': {
86
+ console.error(message)
87
+ break
88
+ }
89
+ default: {
90
+ console.log(message)
91
+ break
92
+ }
93
+ }
94
+ }
95
+
96
+ const logWithContext = (
97
+ level: LogLevel,
98
+ request: Request,
99
+ message: string,
100
+ context?: Record<string, unknown>
101
+ ): void => {
102
+ const store: StoreData = { beforeTime: process.hrtime.bigint() }
103
+ log(level, request, { message, context }, store)
104
+ }
105
+
106
+ return {
107
+ pino: pinoLogger,
108
+ log,
109
+ handleHttpError: (request, error, store) => {
110
+ handleHttpError(request, error, store, options)
111
+ },
112
+ debug: (request, message, context) => {
113
+ logWithContext('DEBUG', request, message, context)
114
+ },
115
+ info: (request, message, context) => {
116
+ logWithContext('INFO', request, message, context)
117
+ },
118
+ warn: (request, message, context) => {
119
+ logWithContext('WARNING', request, message, context)
120
+ },
121
+ error: (request, message, context) => {
122
+ logWithContext('ERROR', request, message, context)
123
+ }
124
+ }
125
+ }
@@ -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
+ interface 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, Request, StoreData } from '../interfaces'
2
+
3
+ interface LogToTransportsInput {
4
+ level: LogLevel
5
+ request: Request
6
+ data: Record<string, unknown>
7
+ store: StoreData
8
+ options: Options
9
+ }
10
+
11
+ export const logToTransports = (
12
+ ...args:
13
+ | [LogToTransportsInput]
14
+ | [LogLevel, Request, 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
+ }