@pori15/logixlysia 0.0.1 → 6.0.2
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.
- package/README.md +1 -1
- package/dist/index.d.ts +45 -14
- package/dist/index.js +7 -7
- package/package.json +13 -4
- package/src/Error/errors.ts +258 -258
- package/src/Error/type.ts +51 -51
- package/src/extensions/banner.ts +26 -26
- package/src/extensions/index.ts +28 -28
- package/src/helpers/status.ts +58 -58
- package/src/index.ts +162 -141
- package/src/interfaces.ts +138 -136
- package/src/logger/create-logger.ts +240 -246
- package/src/logger/handle-http-error.ts +60 -62
- package/src/logger/index.ts +138 -125
- package/src/output/file.ts +85 -85
- package/src/output/fs.ts +5 -5
- package/src/output/index.ts +58 -58
- package/src/output/rotation-manager.ts +122 -122
- package/src/utils/error.ts +13 -15
- package/src/utils/handle-error.ts +41 -46
- package/src/utils/rotation.ts +91 -91
|
@@ -1,122 +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
|
-
}
|
|
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
|
+
}
|
package/src/utils/error.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
return message
|
|
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
|
+
return message
|
|
13
|
+
}
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* @see https://www.rfc-editor.org/rfc/rfc9457.html
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { ProblemError } from
|
|
7
|
-
import { Code } from
|
|
8
|
-
|
|
6
|
+
import { ProblemError } from '../Error/errors'
|
|
7
|
+
import type { Code } from '../Error/type'
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* 默认的状态码与标题映射表 (RFC 标准推荐)
|
|
@@ -18,62 +17,64 @@ const DEFAULT_TITLES: Record<number, string> = {
|
|
|
18
17
|
409: 'Conflict',
|
|
19
18
|
422: 'Unprocessable Entity',
|
|
20
19
|
500: 'Internal Server Error',
|
|
21
|
-
503: 'Service Unavailable'
|
|
22
|
-
}
|
|
20
|
+
503: 'Service Unavailable'
|
|
21
|
+
}
|
|
23
22
|
|
|
24
23
|
export const normalizeToProblem = (
|
|
25
24
|
error: any,
|
|
26
25
|
code: Code,
|
|
27
26
|
path: string,
|
|
28
|
-
typeBaseUrl
|
|
27
|
+
typeBaseUrl = 'about:blank'
|
|
29
28
|
): ProblemError => {
|
|
30
29
|
// 1. 如果已经是 ProblemError,直接补充 instance 并返回
|
|
31
30
|
if (error instanceof ProblemError) {
|
|
32
31
|
// 如果没有 instance,自动补全为当前请求路径
|
|
33
|
-
return error.instance
|
|
34
|
-
error
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
return error.instance
|
|
33
|
+
? error
|
|
34
|
+
: new ProblemError(
|
|
35
|
+
error.type,
|
|
36
|
+
error.title,
|
|
37
|
+
error.status,
|
|
38
|
+
error.detail,
|
|
39
|
+
path,
|
|
40
|
+
error.extensions
|
|
41
|
+
)
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
// 2. 初始化默认值
|
|
44
|
-
let status = 500
|
|
45
|
-
let title = 'Internal Server Error'
|
|
46
|
-
let detail = error instanceof Error ? error.message : String(error)
|
|
47
|
-
let extensions: Record<string, unknown> = {}
|
|
45
|
+
let status = 500
|
|
46
|
+
let title = 'Internal Server Error'
|
|
47
|
+
let detail = error instanceof Error ? error.message : String(error)
|
|
48
|
+
let extensions: Record<string, unknown> = {}
|
|
48
49
|
|
|
49
50
|
// 3. 识别 Elysia 内置错误码并“对齐”标准
|
|
50
51
|
switch (code) {
|
|
51
52
|
case 'VALIDATION':
|
|
52
|
-
status = 400
|
|
53
|
-
title = 'Validation Failed'
|
|
53
|
+
status = 400
|
|
54
|
+
title = 'Validation Failed'
|
|
54
55
|
// 提取 Elysia 的校验细节
|
|
55
|
-
extensions = { errors: error.all || [] }
|
|
56
|
-
break
|
|
56
|
+
extensions = { errors: error.all || [] }
|
|
57
|
+
break
|
|
57
58
|
case 'NOT_FOUND':
|
|
58
|
-
status = 404
|
|
59
|
-
title = 'Resource Not Found'
|
|
60
|
-
break
|
|
59
|
+
status = 404
|
|
60
|
+
title = 'Resource Not Found'
|
|
61
|
+
break
|
|
61
62
|
case 'PARSE':
|
|
62
|
-
status = 400
|
|
63
|
-
title = 'Invalid Payload'
|
|
64
|
-
detail = 'The request body could not be parsed as valid JSON.'
|
|
65
|
-
break
|
|
63
|
+
status = 400
|
|
64
|
+
title = 'Invalid Payload'
|
|
65
|
+
detail = 'The request body could not be parsed as valid JSON.'
|
|
66
|
+
break
|
|
66
67
|
case 'INVALID_COOKIE_SIGNATURE':
|
|
67
|
-
status = 401
|
|
68
|
-
title = 'Invalid Credentials'
|
|
69
|
-
break
|
|
68
|
+
status = 401
|
|
69
|
+
title = 'Invalid Credentials'
|
|
70
|
+
break
|
|
70
71
|
default:
|
|
71
72
|
// 如果错误对象本身带有状态码(比如某些库抛出的)
|
|
72
73
|
if (typeof error?.status === 'number') {
|
|
73
|
-
status = error.status
|
|
74
|
-
title = DEFAULT_TITLES[status] || 'Unknown Error'
|
|
74
|
+
status = error.status
|
|
75
|
+
title = DEFAULT_TITLES[status] || 'Unknown Error'
|
|
75
76
|
}
|
|
76
|
-
break
|
|
77
|
+
break
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
// 4. 构造并返回标准的 ProblemError
|
|
@@ -84,9 +85,8 @@ export const normalizeToProblem = (
|
|
|
84
85
|
detail,
|
|
85
86
|
path,
|
|
86
87
|
extensions
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
|
-
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
90
|
|
|
91
91
|
export interface ProblemJson {
|
|
92
92
|
type?: string
|
|
@@ -116,9 +116,7 @@ export interface ProblemJsonOptions {
|
|
|
116
116
|
format?: (error: unknown, request: Request) => ProblemJson | null
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
const isErrorWithStatus = (
|
|
120
|
-
value: unknown
|
|
121
|
-
): value is { status: number } =>
|
|
119
|
+
const isErrorWithStatus = (value: unknown): value is { status: number } =>
|
|
122
120
|
typeof value === 'object' &&
|
|
123
121
|
value !== null &&
|
|
124
122
|
'status' in value &&
|
|
@@ -251,9 +249,7 @@ export function formatProblemJsonLog(
|
|
|
251
249
|
// 标题行
|
|
252
250
|
const statusStr = problem.status.toString()
|
|
253
251
|
const emoji = getStatusEmoji(problem.status)
|
|
254
|
-
parts.push(
|
|
255
|
-
`\n${emoji} [HTTP ${statusStr}] ${request.method} ${url.pathname}`
|
|
256
|
-
)
|
|
252
|
+
parts.push(`\n${emoji} [HTTP ${statusStr}] ${request.method} ${url.pathname}`)
|
|
257
253
|
|
|
258
254
|
// Problem JSON 内容
|
|
259
255
|
parts.push(` Type: ${problem.type}`)
|
|
@@ -267,8 +263,7 @@ export function formatProblemJsonLog(
|
|
|
267
263
|
|
|
268
264
|
// 扩展字段
|
|
269
265
|
const extensions = Object.entries(problem).filter(
|
|
270
|
-
([key]) =>
|
|
271
|
-
!['type', 'title', 'status', 'detail', 'instance'].includes(key)
|
|
266
|
+
([key]) => !['type', 'title', 'status', 'detail', 'instance'].includes(key)
|
|
272
267
|
)
|
|
273
268
|
if (extensions.length > 0) {
|
|
274
269
|
parts.push(' Extensions:')
|
package/src/utils/rotation.ts
CHANGED
|
@@ -1,91 +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
|
-
}
|
|
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
|
+
}
|