diff --git a/packages/logger-middleware/README.md b/packages/logger-middleware/README.md index 3594673eaa8..291e9e2d309 100644 --- a/packages/logger-middleware/README.md +++ b/packages/logger-middleware/README.md @@ -30,7 +30,9 @@ You can use the `format` option to customize the log format. The following token - `%date` - Date and time in Apache/nginx format (dd/Mon/yyyy:HH:mm:ss ±zzzz) - `%dateISO` - Date and time in ISO format - `%duration` - Request duration in milliseconds +- `%durationPretty` - Request duration in a human-readable format (e.g., `1.20s`, `120ms`) - `%contentLength` - Response Content-Length header +- `%contentLengthPretty` - Response Content-Length in a human-readable format (e.g., `1.2 kB`, `120 B`) - `%contentType` - Response Content-Type header - `%host` - Request URL host - `%hostname` - Request URL hostname @@ -50,11 +52,11 @@ You can use the `format` option to customize the log format. The following token let router = createRouter({ middleware: [ logger({ - format: '%method %path - %status (%duration ms)', + format: '%method %path - %status (%durationPretty) %contentLengthPretty', }), ], }) -// Logs: GET /users/123 - 200 (42 ms) +// Logs: GET /users/123 - 200 (42ms) 1.2 kB ``` For Apache-style combined log format, you can use the following format: @@ -69,6 +71,26 @@ let router = createRouter({ }) ``` +### Colorized Output + +You can enable colorized output by setting the `colors` option to `true`. This is useful for development environments to improve readability. + +```ts +let router = createRouter({ + middleware: [ + logger({ + colors: true, + }), + ], +}) +``` + +When `colors` is enabled, the following tokens will be color-coded: +- `%method` +- `%status` +- `%durationPretty` +- `%contentLengthPretty` + ### Custom Logger You can use a custom logger to write logs to a file or other stream. diff --git a/packages/logger-middleware/src/lib/colors.ts b/packages/logger-middleware/src/lib/colors.ts new file mode 100644 index 00000000000..fa9601fc581 --- /dev/null +++ b/packages/logger-middleware/src/lib/colors.ts @@ -0,0 +1,80 @@ +/** + * ANSI color codes for terminal output. + */ +const RESET = '\x1b[0m' +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' +const YELLOW = '\x1b[33m' +const RED = '\x1b[31m' +const MAGENTA = '\x1b[35m' + +/** + * Colorizes a log token based on its name and value. + * + * @param tokenName The name of the token to colorize (e.g., 'status', 'method'). + * @param value The string value of the token to be colorized. + * @param rawValue The raw, numeric value of the token, used for threshold-based coloring. + * @returns The colorized string, wrapped in ANSI color codes. + */ +export function colorizeToken(tokenName: string, value: string, rawValue?: number): string { + switch (tokenName) { + case 'status': + return colorizeStatus(value, rawValue) + case 'durationPretty': + return colorizeDuration(value, rawValue) + case 'contentLengthPretty': + return colorizeContentLength(value, rawValue) + case 'method': + return colorizeMethod(value) + default: + return value + } +} + +function colorizeStatus(value: string, status?: number): string { + if (!status) return value + if (status >= 500) return `${RED}${value}${RESET}` + if (status >= 400) return `${YELLOW}${value}${RESET}` + if (status >= 300) return `${CYAN}${value}${RESET}` + if (status >= 200) return `${GREEN}${value}${RESET}` + return value +} + +function colorizeDuration(value: string, duration?: number): string { + if (duration === undefined) return value + if (duration >= 1000) return `${RED}${value}${RESET}` + if (duration >= 500) return `${MAGENTA}${value}${RESET}` + if (duration >= 100) return `${YELLOW}${value}${RESET}` + return `${GREEN}${value}${RESET}` +} + +const ONE_MB = 1024 * 1024 +const ONE_HUNDRED_KB = 100 * 1024 +const ONE_KB = 1024 + +function colorizeContentLength(value: string, length?: number): string { + if (length === undefined) return value + if (length >= ONE_MB) return `${RED}${value}${RESET}` + if (length >= ONE_HUNDRED_KB) return `${YELLOW}${value}${RESET}` + if (length >= ONE_KB) return `${CYAN}${value}${RESET}` + return value +} + +function colorizeMethod(value: string): string { + switch (value.toUpperCase()) { + case 'GET': + return `${GREEN}${value}${RESET}` + case 'POST': + return `${CYAN}${value}${RESET}` + case 'PUT': + case 'PATCH': + return `${YELLOW}${value}${RESET}` + case 'DELETE': + return `${RED}${value}${RESET}` + case 'HEAD': + case 'OPTIONS': + return `${MAGENTA}${value}${RESET}` + default: + return value + } +} diff --git a/packages/logger-middleware/src/lib/logger.test.ts b/packages/logger-middleware/src/lib/logger.test.ts index eb657bf3a6b..642a7aff651 100644 --- a/packages/logger-middleware/src/lib/logger.test.ts +++ b/packages/logger-middleware/src/lib/logger.test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'node:test' import { createRouter, route } from '@remix-run/fetch-router' import { logger } from './logger.ts' +import { formatDuration } from './logger.ts' describe('logger', () => { it('logs the request', async () => { @@ -35,4 +36,104 @@ describe('logger', () => { let message = messages[0] assert.match(message, /\[\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] GET \/ \d+ \d+/) }) + + it('logs the request with pretty formatters', async () => { + let routes = route({ + home: '/', + }) + + let messages: string[] = [] + + let router = createRouter({ + middleware: [ + logger({ + log: (message) => messages.push(message), + format: '%method %path %status %durationPretty %contentLengthPretty', + }), + ], + }) + + router.map(routes.home, () => { + return new Response('Home', { + headers: { + 'Content-Length': '1234', + 'Content-Type': 'text/plain', + }, + }) + }) + + let response = await router.fetch('https://remix.run') + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Home') + + assert.equal(messages.length, 1) + let message = messages[0] + assert.match(message, /GET \/ 200 \d+ms 1.2 kB/) + }) + + it('logs the request with colors', async () => { + let routes = route({ + home: '/', + }) + + let messages: string[] = [] + + let router = createRouter({ + middleware: [ + logger({ + log: (message) => messages.push(message), + format: '%method %path %status', + colors: true, + }), + ], + }) + + router.map(routes.home, () => { + return new Response('Home', { + headers: { + 'Content-Length': '4', + 'Content-Type': 'text/plain', + }, + }) + }) + + let response = await router.fetch('https://remix.run') + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Home') + + assert.equal(messages.length, 1) + let message = messages[0] + assert.match(message, /\x1b\[32mGET\x1b\[0m \/ \x1b\[32m200\x1b\[0m/) + }) +}) + +describe('formatDuration', () => { + it('formats milliseconds correctly', () => { + assert.equal(formatDuration(0), '0ms') + assert.equal(formatDuration(999), '999ms') + assert.equal(formatDuration(100), '100ms') + }) + + it('formats seconds correctly', () => { + assert.equal(formatDuration(1000), '1.00s') + assert.equal(formatDuration(1500), '1.50s') + assert.equal(formatDuration(59999), '60.00s') // Just under a minute + assert.equal(formatDuration(12345), '12.35s') // Rounded + }) + + it('formats minutes correctly', () => { + assert.equal(formatDuration(60 * 1000), '1.00m') // Exactly one minute + assert.equal(formatDuration(90 * 1000), '1.50m') // One and a half minutes + assert.equal(formatDuration(120 * 1000 + 345), '2.01m') // Two minutes and some ms + assert.equal(formatDuration(3599 * 1000), '59.98m') // Just under an hour + }) + + it('formats hours correctly', () => { + assert.equal(formatDuration(60 * 60 * 1000), '1.00h') // Exactly one hour + assert.equal(formatDuration(90 * 60 * 1000), '1.50h') // One and a half hours + assert.equal(formatDuration(7200 * 1000 + 12345), '2.00h') // Two hours and some s + assert.equal(formatDuration(7200 * 1000 * 1000), '2000.00h') // Large hours value + }) }) diff --git a/packages/logger-middleware/src/lib/logger.ts b/packages/logger-middleware/src/lib/logger.ts index 88572a24d64..4e4aefc33f4 100644 --- a/packages/logger-middleware/src/lib/logger.ts +++ b/packages/logger-middleware/src/lib/logger.ts @@ -1,4 +1,5 @@ import type { Middleware } from '@remix-run/fetch-router' +import { colorizeToken } from './colors.ts' /** * Options for the `logger` middleware. @@ -12,7 +13,9 @@ export interface LoggerOptions { * - `%date` - The date and time of the request in Apache/nginx log format (dd/Mon/yyyy:HH:mm:ss ±zzzz) * - `%dateISO` - The date and time of the request in ISO format * - `%duration` - The duration of the request in milliseconds + * - `%durationPretty` - The duration of the request in a human-readable format (e.g., '1.2s', '120ms') * - `%contentLength` - The `Content-Length` header of the response + * - `%contentLengthPretty` - The `Content-Length` header of the response in a human-readable format (e.g., '1.2kB', '120B') * - `%contentType` - The `Content-Type` header of the response * - `%host` - The host of the request URL * - `%hostname` - The hostname of the request URL @@ -28,7 +31,7 @@ export interface LoggerOptions { * - `%url` - The full URL of the request * - `%userAgent` - The `User-Agent` header of the request * - * @default '[%date] %method %path %status %contentLength' + * @default '[%date] %method %path %status $durationPretty %contentLengthPretty' */ format?: string /** @@ -37,6 +40,14 @@ export interface LoggerOptions { * @default console.log */ log?: (message: string) => void + /** + * Enables or disables colorized output for the log messages. + * When `true`, tokens like `%status`, `%method`, `%durationPretty`, and `%contentLengthPretty` + * will be color-coded in the output. + * + * @default false + */ + colors?: boolean } /** @@ -46,22 +57,40 @@ export interface LoggerOptions { * @return The logger middleware */ export function logger(options: LoggerOptions = {}): Middleware { - let { format = '[%date] %method %path %status %contentLength', log = console.log } = options + let { + format = '[%date] %method %path %status %durationPretty %contentLengthPretty', + log = console.log, + colors = false, + } = options return async ({ request, url }, next) => { let start = new Date() let response = await next() let end = new Date() + let duration = end.getTime() - start.getTime() + let contentLength = response.headers.get('Content-Length') + let contentLengthNum = contentLength ? parseInt(contentLength, 10) : undefined let tokens: Record string> = { date: () => formatApacheDate(start), dateISO: () => start.toISOString(), - duration: () => String(end.getTime() - start.getTime()), - contentLength: () => response.headers.get('Content-Length') ?? '-', + duration: () => String(duration), + durationPretty: () => { + let value = formatDuration(duration) + return colors ? colorizeToken('durationPretty', value, duration) : value + }, + contentLength: () => contentLength ?? '-', + contentLengthPretty: () => { + let value = contentLength ? formatFileSize(contentLengthNum!) : '-' + return colors ? colorizeToken('contentLengthPretty', value, contentLengthNum) : value + }, contentType: () => response.headers.get('Content-Type') ?? '-', host: () => url.host, hostname: () => url.hostname, - method: () => request.method, + method: () => { + let value = request.method + return colors ? colorizeToken('method', value) : value + }, path: () => url.pathname + url.search, pathname: () => url.pathname, port: () => url.port, @@ -69,7 +98,10 @@ export function logger(options: LoggerOptions = {}): Middleware { query: () => url.search, referer: () => request.headers.get('Referer') ?? '-', search: () => url.search, - status: () => String(response.status), + status: () => { + let value = String(response.status) + return colors ? colorizeToken('status', value, response.status) : value + }, statusText: () => response.statusText, url: () => url.href, userAgent: () => request.headers.get('User-Agent') ?? '-', @@ -106,3 +138,35 @@ function formatApacheDate(date: Date): string { return `${day}/${month}/${year}:${hours}:${minutes}:${seconds} ${timezone}` } + +/** + * Formats a file size in a human-readable format. + * Example: 1024 -> "1.0 kB" + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + let units = ['B', 'kB', 'MB', 'GB', 'TB'] + let i = Math.floor(Math.log(bytes) / Math.log(1024)) + let size = bytes / Math.pow(1024, i) + return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i] +} + +const MINUTE_IN_MS = 60 * 1000 +const HOUR_IN_MS = 60 * MINUTE_IN_MS + +/** + * Formats a duration in a human-readable format. + * Example: 1200 -> "1.20s" + */ +export function formatDuration(ms: number): string { + if (ms >= HOUR_IN_MS) { + return `${(ms / HOUR_IN_MS).toFixed(2)}h` + } + if (ms >= MINUTE_IN_MS) { + return `${(ms / MINUTE_IN_MS).toFixed(2)}m` + } + if (ms >= 1000) { + return `${(ms / 1000).toFixed(2)}s` + } + return `${ms}ms` +}