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,26 @@
1
+ import elysiaPkg from 'elysia/package.json'
2
+
3
+ const centerText = (text: string, width: number): string => {
4
+ if (text.length >= width) {
5
+ return text.slice(0, width)
6
+ }
7
+
8
+ const left = Math.floor((width - text.length) / 2)
9
+ const right = width - text.length - left
10
+ return `${' '.repeat(left)}${text}${' '.repeat(right)}`
11
+ }
12
+
13
+ export const renderBanner = (message: string): string => {
14
+ const versionLine = `Elysia v${elysiaPkg.version}`
15
+ const contentWidth = Math.max(message.length, versionLine.length)
16
+ const innerWidth = contentWidth + 4 // 2 spaces padding on both sides
17
+
18
+ const top = `┌${'─'.repeat(innerWidth)}┐`
19
+ const bot = `└${'─'.repeat(innerWidth)}┘`
20
+ const empty = `│${' '.repeat(innerWidth)}│`
21
+
22
+ const versionRow = `│${centerText(versionLine, innerWidth)}│`
23
+ const messageRow = `│ ${message}${' '.repeat(Math.max(0, innerWidth - message.length - 4))} │`
24
+
25
+ return [top, empty, versionRow, empty, messageRow, empty, bot].join('\n')
26
+ }
@@ -0,0 +1,28 @@
1
+ import type { Options } from '../interfaces'
2
+ import { renderBanner } from './banner'
3
+
4
+ export const startServer = (
5
+ server: { port?: number; hostname?: string; protocol?: string | null },
6
+ options: Options
7
+ ): void => {
8
+ const showStartupMessage = options.config?.showStartupMessage ?? true
9
+ if (!showStartupMessage) {
10
+ return
11
+ }
12
+
13
+ const { port, hostname, protocol } = server
14
+ if (port === undefined || !hostname || !protocol) {
15
+ return
16
+ }
17
+
18
+ const url = `${protocol}://${hostname}:${port}`
19
+ const message = `🦊 Elysia is running at ${url}`
20
+
21
+ const format = options.config?.startupMessageFormat ?? 'banner'
22
+ if (format === 'simple') {
23
+ console.log(message)
24
+ return
25
+ }
26
+
27
+ console.log(renderBanner(message))
28
+ }
@@ -0,0 +1,58 @@
1
+ import { StatusMap } from 'elysia'
2
+
3
+ const DIGITS_ONLY = /^\d+$/
4
+ const DELIMITERS = /[_-]+/g
5
+ const CAMEL_BOUNDARY_1 = /([a-z0-9])([A-Z])/g
6
+ const CAMEL_BOUNDARY_2 = /([A-Z])([A-Z][a-z])/g
7
+ const APOSTROPHES = /['’]/g
8
+ const NON_ALPHANUMERIC = /[^a-z0-9\s]+/g
9
+ const WHITESPACE = /\s+/g
10
+
11
+ const normalizeStatusName = (value: string): string => {
12
+ // Handles common variants:
13
+ // - case differences: "not found" vs "Not Found"
14
+ // - spacing/punctuation: "Not-Found", "not_found"
15
+ // - camelCase/PascalCase: "InternalServerError"
16
+ const trimmed = value.trim()
17
+ if (!trimmed) {
18
+ return ''
19
+ }
20
+
21
+ return trimmed
22
+ .replace(DELIMITERS, ' ')
23
+ .replace(CAMEL_BOUNDARY_1, '$1 $2')
24
+ .replace(CAMEL_BOUNDARY_2, '$1 $2')
25
+ .replace(APOSTROPHES, '')
26
+ .toLowerCase()
27
+ .replace(NON_ALPHANUMERIC, ' ')
28
+ .replace(WHITESPACE, ' ')
29
+ .trim()
30
+ }
31
+
32
+ const STATUS_BY_NORMALIZED_NAME = (() => {
33
+ const map = new Map<string, number>()
34
+
35
+ for (const [name, code] of Object.entries(StatusMap)) {
36
+ map.set(normalizeStatusName(name), code)
37
+ }
38
+
39
+ return map
40
+ })()
41
+
42
+ export const getStatusCode = (value: unknown): number => {
43
+ if (typeof value === 'number' && Number.isFinite(value)) {
44
+ return value
45
+ }
46
+
47
+ if (typeof value === 'string') {
48
+ const trimmed = value.trim()
49
+ if (DIGITS_ONLY.test(trimmed)) {
50
+ return Number(trimmed)
51
+ }
52
+
53
+ const known = STATUS_BY_NORMALIZED_NAME.get(normalizeStatusName(trimmed))
54
+ return known ?? 500
55
+ }
56
+
57
+ return 500
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { Elysia, type SingletonBase } from 'elysia'
2
+ import { startServer } from './extensions'
3
+ import type { LogixlysiaStore, Options } from './interfaces'
4
+ import { createLogger } from './logger'
5
+
6
+ export type Logixlysia = Elysia<
7
+ 'Logixlysia',
8
+ SingletonBase & { store: LogixlysiaStore }
9
+ >
10
+
11
+ export const logixlysia = (options: Options = {}): Logixlysia => {
12
+ const didCustomLog = new WeakSet<Request>()
13
+ const baseLogger = createLogger(options)
14
+ const logger = {
15
+ ...baseLogger,
16
+ debug: (
17
+ request: Request,
18
+ message: string,
19
+ context?: Record<string, unknown>
20
+ ) => {
21
+ didCustomLog.add(request)
22
+ baseLogger.debug(request, message, context)
23
+ },
24
+ info: (
25
+ request: Request,
26
+ message: string,
27
+ context?: Record<string, unknown>
28
+ ) => {
29
+ didCustomLog.add(request)
30
+ baseLogger.info(request, message, context)
31
+ },
32
+ warn: (
33
+ request: Request,
34
+ message: string,
35
+ context?: Record<string, unknown>
36
+ ) => {
37
+ didCustomLog.add(request)
38
+ baseLogger.warn(request, message, context)
39
+ },
40
+ error: (
41
+ request: Request,
42
+ message: string,
43
+ context?: Record<string, unknown>
44
+ ) => {
45
+ didCustomLog.add(request)
46
+ baseLogger.error(request, message, context)
47
+ }
48
+ }
49
+
50
+ const app = new Elysia({
51
+ name: 'Logixlysia',
52
+ detail: {
53
+ description:
54
+ 'Logixlysia is a plugin for Elysia that provides a logger and pino logger.',
55
+ tags: ['logging', 'pino']
56
+ }
57
+ })
58
+
59
+ return (
60
+ app
61
+ .state('logger', logger)
62
+ .state('pino', logger.pino)
63
+ .state('beforeTime', BigInt(0))
64
+ .onStart(({ server }) => {
65
+ if (server) {
66
+ startServer(server, options)
67
+ }
68
+ })
69
+ .onRequest(({ store }) => {
70
+ store.beforeTime = process.hrtime.bigint()
71
+ })
72
+ .onAfterHandle(({ request, set, store }) => {
73
+ if (didCustomLog.has(request)) {
74
+ return
75
+ }
76
+
77
+ const status = typeof set.status === 'number' ? set.status : 200
78
+ let level: 'INFO' | 'WARNING' | 'ERROR' = 'INFO'
79
+ if (status >= 500) {
80
+ level = 'ERROR'
81
+ } else if (status >= 400) {
82
+ level = 'WARNING'
83
+ }
84
+
85
+ logger.log(level, request, { status }, store)
86
+ })
87
+ .onError(({ request, error, store }) => {
88
+ logger.handleHttpError(request, error, store)
89
+ })
90
+ // Ensure plugin lifecycle hooks (onRequest/onAfterHandle/onError) apply to the parent app.
91
+ .as('scoped') as unknown as Logixlysia
92
+ )
93
+ }
94
+
95
+ export type {
96
+ Logger,
97
+ LogixlysiaContext,
98
+ LogixlysiaStore,
99
+ LogLevel,
100
+ Options,
101
+ Pino,
102
+ StoreData,
103
+ Transport
104
+ } from './interfaces'
105
+
106
+ export default logixlysia
@@ -0,0 +1,117 @@
1
+ import type {
2
+ Logger as PinoLogger,
3
+ LoggerOptions as PinoLoggerOptions
4
+ } from 'pino'
5
+
6
+ export type Pino = PinoLogger<never, boolean>
7
+
8
+ export type LogLevel = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR'
9
+
10
+ export type StoreData = {
11
+ beforeTime: bigint
12
+ }
13
+
14
+ export type LogixlysiaStore = {
15
+ logger: Logger
16
+ pino: Pino
17
+ beforeTime?: bigint
18
+ [key: string]: unknown
19
+ }
20
+
21
+ export type Transport = {
22
+ log: (
23
+ level: LogLevel,
24
+ message: string,
25
+ meta?: Record<string, unknown>
26
+ ) => void | Promise<void>
27
+ }
28
+
29
+ export type LogRotationConfig = {
30
+ /**
31
+ * Max log file size before rotation, e.g. '10m', '5k', or a byte count.
32
+ */
33
+ maxSize?: string | number
34
+ /**
35
+ * Keep at most N files or keep files for a duration like '7d'.
36
+ */
37
+ maxFiles?: number | string
38
+ /**
39
+ * Rotate at a fixed interval, e.g. '1d', '12h'.
40
+ */
41
+ interval?: string
42
+ compress?: boolean
43
+ compression?: 'gzip'
44
+ }
45
+
46
+ export type Options = {
47
+ config?: {
48
+ showStartupMessage?: boolean
49
+ startupMessageFormat?: 'simple' | 'banner'
50
+ useColors?: boolean
51
+ ip?: boolean
52
+ timestamp?: {
53
+ translateTime?: string
54
+ }
55
+ customLogFormat?: string
56
+
57
+ // Outputs
58
+ transports?: Transport[]
59
+ useTransportsOnly?: boolean
60
+ disableInternalLogger?: boolean
61
+ disableFileLogging?: boolean
62
+ logFilePath?: string
63
+ logRotation?: LogRotationConfig
64
+
65
+ // Pino
66
+ pino?: (PinoLoggerOptions & { prettyPrint?: boolean }) | undefined
67
+ }
68
+ }
69
+
70
+ export class HttpError extends Error {
71
+ readonly status: number
72
+
73
+ constructor(status: number, message: string) {
74
+ super(message)
75
+ this.status = status
76
+ }
77
+ }
78
+
79
+ export type Logger = {
80
+ pino: Pino
81
+ log: (
82
+ level: LogLevel,
83
+ request: RequestInfo,
84
+ data: Record<string, unknown>,
85
+ store: StoreData
86
+ ) => void
87
+ handleHttpError: (
88
+ request: RequestInfo,
89
+ error: unknown,
90
+ store: StoreData
91
+ ) => void
92
+ debug: (
93
+ request: RequestInfo,
94
+ message: string,
95
+ context?: Record<string, unknown>
96
+ ) => void
97
+ info: (
98
+ request: RequestInfo,
99
+ message: string,
100
+ context?: Record<string, unknown>
101
+ ) => void
102
+ warn: (
103
+ request: RequestInfo,
104
+ message: string,
105
+ context?: Record<string, unknown>
106
+ ) => void
107
+ error: (
108
+ request: RequestInfo,
109
+ message: string,
110
+ context?: Record<string, unknown>
111
+ ) => void
112
+ }
113
+
114
+ export type LogixlysiaContext = {
115
+ request: Request
116
+ store: LogixlysiaStore
117
+ }
@@ -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
+ RequestInfo,
8
+ StoreData
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: RequestInfo): 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: RequestInfo
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,51 @@
1
+ import type { LogLevel, Options, RequestInfo, StoreData } from '../interfaces'
2
+ import { logToTransports } from '../output'
3
+ import { logToFile } from '../output/file'
4
+ import { parseError } from '../utils/error'
5
+
6
+ const isErrorWithStatus = (
7
+ value: unknown
8
+ ): value is { status: number; message?: string } =>
9
+ typeof value === 'object' &&
10
+ value !== null &&
11
+ 'status' in value &&
12
+ typeof (value as { status?: unknown }).status === 'number'
13
+
14
+ export const handleHttpError = (
15
+ request: RequestInfo,
16
+ error: unknown,
17
+ store: StoreData,
18
+ options: Options
19
+ ): void => {
20
+ const config = options.config
21
+ const useTransportsOnly = config?.useTransportsOnly === true
22
+ const disableInternalLogger = config?.disableInternalLogger === true
23
+ const disableFileLogging = config?.disableFileLogging === true
24
+
25
+ const status = isErrorWithStatus(error) ? error.status : 500
26
+ const message = parseError(error)
27
+
28
+ const level: LogLevel = 'ERROR'
29
+ const data: Record<string, unknown> = { status, message, error }
30
+
31
+ logToTransports({ level, request, data, store, options })
32
+
33
+ if (!(useTransportsOnly || disableFileLogging)) {
34
+ const filePath = config?.logFilePath
35
+ if (filePath) {
36
+ logToFile({ filePath, level, request, data, store, options }).catch(
37
+ () => {
38
+ // Ignore errors
39
+ }
40
+ )
41
+ }
42
+ }
43
+
44
+ if (useTransportsOnly || disableInternalLogger) {
45
+ return
46
+ }
47
+
48
+ console.error(
49
+ `${level} ${request.method} ${new URL(request.url).pathname} ${message}`
50
+ )
51
+ }