From 94b90b703a40671c2d8c6d8c3051552cd9e94dea Mon Sep 17 00:00:00 2001 From: Divine Date: Tue, 25 Nov 2025 13:34:15 +0100 Subject: [PATCH 1/3] feat(logger): Enhance logging with pretty formatters and color support --- packages/logger-middleware/README.md | 26 ++++- packages/logger-middleware/src/lib/colors.ts | 78 ++++++++++++++ .../logger-middleware/src/lib/logger.test.ts | 101 ++++++++++++++++++ packages/logger-middleware/src/lib/logger.ts | 76 +++++++++++-- 4 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 packages/logger-middleware/src/lib/colors.ts 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..3a17bcd5ead --- /dev/null +++ b/packages/logger-middleware/src/lib/colors.ts @@ -0,0 +1,78 @@ +/** + * 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}` +} + +function colorizeContentLength(value: string, length?: number): string { + const ONE_MB = 1024 * 1024 + const ONE_HUNDRED_KB = 100 * 1024 + const ONE_KB = 1024 + + 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': + return `${YELLOW}${value}${RESET}` + case 'PATCH': + return `${MAGENTA}${value}${RESET}` + case 'DELETE': + return `${RED}${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` +} From ae697a897b77448dfbfd788f2dc0dad15ca75e56 Mon Sep 17 00:00:00 2001 From: Divine Date: Tue, 25 Nov 2025 13:58:16 +0100 Subject: [PATCH 2/3] Chore(added HEAD and OPTIONS method to the color function) --- packages/logger-middleware/src/lib/colors.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/logger-middleware/src/lib/colors.ts b/packages/logger-middleware/src/lib/colors.ts index 3a17bcd5ead..fa9601fc581 100644 --- a/packages/logger-middleware/src/lib/colors.ts +++ b/packages/logger-middleware/src/lib/colors.ts @@ -48,11 +48,11 @@ function colorizeDuration(value: string, duration?: number): string { return `${GREEN}${value}${RESET}` } -function colorizeContentLength(value: string, length?: number): string { - const ONE_MB = 1024 * 1024 - const ONE_HUNDRED_KB = 100 * 1024 - const ONE_KB = 1024 +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}` @@ -67,11 +67,13 @@ function colorizeMethod(value: string): string { case 'POST': return `${CYAN}${value}${RESET}` case 'PUT': - return `${YELLOW}${value}${RESET}` case 'PATCH': - return `${MAGENTA}${value}${RESET}` + return `${YELLOW}${value}${RESET}` case 'DELETE': return `${RED}${value}${RESET}` + case 'HEAD': + case 'OPTIONS': + return `${MAGENTA}${value}${RESET}` default: return value } From dbf09501c9bb26bc47819d5519fb707c29f1a091 Mon Sep 17 00:00:00 2001 From: Divine Date: Thu, 27 Nov 2025 13:35:41 +0100 Subject: [PATCH 3/3] made sure color only works in supported env and updated README --- packages/logger-middleware/README.md | 4 +- .../src/lib/colorizer.test.ts | 153 ++++++++++++++++++ .../logger-middleware/src/lib/colorizer.ts | 68 ++++++++ packages/logger-middleware/src/lib/colors.ts | 80 --------- .../logger-middleware/src/lib/logger.test.ts | 101 ------------ packages/logger-middleware/src/lib/logger.ts | 48 +++--- 6 files changed, 247 insertions(+), 207 deletions(-) create mode 100644 packages/logger-middleware/src/lib/colorizer.test.ts create mode 100644 packages/logger-middleware/src/lib/colorizer.ts delete mode 100644 packages/logger-middleware/src/lib/colors.ts diff --git a/packages/logger-middleware/README.md b/packages/logger-middleware/README.md index 291e9e2d309..c24b62ba205 100644 --- a/packages/logger-middleware/README.md +++ b/packages/logger-middleware/README.md @@ -73,7 +73,7 @@ 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. +You can enable colorized output by setting the `colors` option to `true`. This is useful for development environments to improve readability. Colorization is automatically disabled in environments that don't support it (e.g., non-TTY terminals or when the `NO_COLOR` environment variable is set). ```ts let router = createRouter({ @@ -88,7 +88,9 @@ let router = createRouter({ When `colors` is enabled, the following tokens will be color-coded: - `%method` - `%status` +- `%duration` - `%durationPretty` +- `%contentLength` - `%contentLengthPretty` ### Custom Logger diff --git a/packages/logger-middleware/src/lib/colorizer.test.ts b/packages/logger-middleware/src/lib/colorizer.test.ts new file mode 100644 index 00000000000..fd8977dcee6 --- /dev/null +++ b/packages/logger-middleware/src/lib/colorizer.test.ts @@ -0,0 +1,153 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { Colorizer } from './colorizer.ts' + +const RESET = '\x1b[0m' +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' +const YELLOW = '\x1b[33m' +const RED = '\x1b[31m' +const MAGENTA = '\x1b[35m' + +describe('Colorizer', () => { + describe('with colors disabled', () => { + let colorizer = new Colorizer(false) + + it('status returns plain code', () => { + assert.strictEqual(colorizer.status(200), '200') + assert.strictEqual(colorizer.status(404), '404') + assert.strictEqual(colorizer.status(500), '500') + }) + + it('method returns plain method', () => { + assert.strictEqual(colorizer.method('GET'), 'GET') + assert.strictEqual(colorizer.method('POST'), 'POST') + }) + + it('duration returns plain value', () => { + assert.strictEqual(colorizer.duration(50, '50ms'), '50ms') + assert.strictEqual(colorizer.duration(1500, '1.5s'), '1.5s') + }) + + it('contentLength returns plain value', () => { + assert.strictEqual(colorizer.contentLength(1024, '1KB'), '1KB') + assert.strictEqual(colorizer.contentLength(undefined, 'N/A'), 'N/A') + }) + }) + + describe('status method', () => { + let colorizer = new Colorizer(true) + + it('handles 2xx codes', () => { + let result = colorizer.status(200) + assert.ok(result === '200' || result === `${GREEN}200${RESET}`) + }) + + it('handles 3xx codes', () => { + let result = colorizer.status(302) + assert.ok(result === '302' || result === `${CYAN}302${RESET}`) + }) + + it('handles 4xx codes', () => { + let result = colorizer.status(404) + assert.ok(result === '404' || result === `${RED}404${RESET}`) + }) + + it('handles 5xx codes', () => { + let result = colorizer.status(500) + assert.ok(result === '500' || result === `${MAGENTA}500${RESET}`) + }) + + it('handles 1xx codes without color', () => { + assert.strictEqual(colorizer.status(100), '100') + }) + }) + + describe('method colorization', () => { + let colorizer = new Colorizer(true) + + it('handles GET', () => { + let result = colorizer.method('GET') + assert.ok(result === 'GET' || result === `${GREEN}GET${RESET}`) + }) + + it('handles POST', () => { + let result = colorizer.method('POST') + assert.ok(result === 'POST' || result === `${CYAN}POST${RESET}`) + }) + + it('handles PUT and PATCH', () => { + let put = colorizer.method('PUT') + let patch = colorizer.method('PATCH') + assert.ok(put === 'PUT' || put === `${YELLOW}PUT${RESET}`) + assert.ok(patch === 'PATCH' || patch === `${YELLOW}PATCH${RESET}`) + }) + + it('handles DELETE', () => { + let result = colorizer.method('DELETE') + assert.ok(result === 'DELETE' || result === `${RED}DELETE${RESET}`) + }) + + it('handles HEAD and OPTIONS', () => { + let head = colorizer.method('HEAD') + let options = colorizer.method('OPTIONS') + assert.ok(head === 'HEAD' || head === `${MAGENTA}HEAD${RESET}`) + assert.ok(options === 'OPTIONS' || options === `${MAGENTA}OPTIONS${RESET}`) + }) + + it('handles unknown methods', () => { + assert.strictEqual(colorizer.method('UNKNOWN'), 'UNKNOWN') + }) + }) + + describe('duration colorization', () => { + let colorizer = new Colorizer(true) + + it('handles fast durations', () => { + let result = colorizer.duration(50, '50ms') + assert.ok(result === '50ms' || result === `${GREEN}50ms${RESET}`) + }) + + it('handles medium durations', () => { + let result = colorizer.duration(150, '150ms') + assert.ok(result === '150ms' || result === `${YELLOW}150ms${RESET}`) + }) + + it('handles slow durations', () => { + let result = colorizer.duration(600, '600ms') + assert.ok(result === '600ms' || result === `${MAGENTA}600ms${RESET}`) + }) + + it('handles very slow durations', () => { + let result = colorizer.duration(1500, '1.5s') + assert.ok(result === '1.5s' || result === `${RED}1.5s${RESET}`) + }) + }) + + describe('contentLength colorization', () => { + let colorizer = new Colorizer(true) + + it('handles small sizes', () => { + assert.strictEqual(colorizer.contentLength(500, '500B'), '500B') + }) + + it('handles KB sizes', () => { + let result = colorizer.contentLength(2000, '2KB') + assert.ok(result === '2KB' || result === `${CYAN}2KB${RESET}`) + }) + + it('handles 100KB+ sizes', () => { + let result = colorizer.contentLength(150000, '150KB') + assert.ok(result === '150KB' || result === `${YELLOW}150KB${RESET}`) + }) + + it('handles MB+ sizes', () => { + let result = colorizer.contentLength(2000000, '2MB') + assert.ok(result === '2MB' || result === `${RED}2MB${RESET}`) + }) + + it('handles undefined', () => { + assert.strictEqual(colorizer.contentLength(undefined, 'N/A'), 'N/A') + }) + }) +}) diff --git a/packages/logger-middleware/src/lib/colorizer.ts b/packages/logger-middleware/src/lib/colorizer.ts new file mode 100644 index 00000000000..40762b1e127 --- /dev/null +++ b/packages/logger-middleware/src/lib/colorizer.ts @@ -0,0 +1,68 @@ +const RESET = '\x1b[0m' +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' +const YELLOW = '\x1b[33m' +const RED = '\x1b[31m' +const MAGENTA = '\x1b[35m' + +export class Colorizer { + readonly #enabled?: boolean + + constructor(colors?: boolean) { + this.#enabled = colors + } + + #colorize(text: string, color: string): string { + return this.#enabled ? `${color}${text}${RESET}` : text + } + + status(code: number): string { + let value = String(code) + if (!this.#enabled) return value + if (code >= 500) return this.#colorize(value, MAGENTA) + if (code >= 400) return this.#colorize(value, RED) + if (code >= 300) return this.#colorize(value, CYAN) + if (code >= 200) return this.#colorize(value, GREEN) + return value + } + + method(method: string): string { + if (!this.#enabled) return method + switch (method.toUpperCase()) { + case 'GET': + return this.#colorize(method, GREEN) + case 'POST': + return this.#colorize(method, CYAN) + case 'PUT': + case 'PATCH': + return this.#colorize(method, YELLOW) + case 'DELETE': + return this.#colorize(method, RED) + case 'HEAD': + case 'OPTIONS': + return this.#colorize(method, MAGENTA) + default: + return method + } + } + + duration(ms: number, prettyValue: string): string { + if (!this.#enabled) return prettyValue + if (ms >= 1000) return this.#colorize(prettyValue, RED) + if (ms >= 500) return this.#colorize(prettyValue, MAGENTA) + if (ms >= 100) return this.#colorize(prettyValue, YELLOW) + return this.#colorize(prettyValue, GREEN) + } + + contentLength(bytes: number | undefined, prettyValue: string): string { + let ONE_MB = 1024 * 1024 + let ONE_HUNDRED_KB = 100 * 1024 + let ONE_KB = 1024 + + if (!this.#enabled || bytes === undefined) return prettyValue + if (bytes >= ONE_MB) return this.#colorize(prettyValue, RED) + if (bytes >= ONE_HUNDRED_KB) return this.#colorize(prettyValue, YELLOW) + if (bytes >= ONE_KB) return this.#colorize(prettyValue, CYAN) + return prettyValue + } +} diff --git a/packages/logger-middleware/src/lib/colors.ts b/packages/logger-middleware/src/lib/colors.ts deleted file mode 100644 index fa9601fc581..00000000000 --- a/packages/logger-middleware/src/lib/colors.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 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 642a7aff651..eb657bf3a6b 100644 --- a/packages/logger-middleware/src/lib/logger.test.ts +++ b/packages/logger-middleware/src/lib/logger.test.ts @@ -4,7 +4,6 @@ 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 () => { @@ -36,104 +35,4 @@ 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 4e4aefc33f4..d03b35663f5 100644 --- a/packages/logger-middleware/src/lib/logger.ts +++ b/packages/logger-middleware/src/lib/logger.ts @@ -1,5 +1,5 @@ import type { Middleware } from '@remix-run/fetch-router' -import { colorizeToken } from './colors.ts' +import { Colorizer } from './colorizer.ts' /** * Options for the `logger` middleware. @@ -31,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 $durationPretty %contentLengthPretty' + * @default '[%date] %method %path %status %durationPretty %contentLengthPretty' */ format?: string /** @@ -42,8 +42,13 @@ export interface LoggerOptions { 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. + * When set to `true`, colorized output is enabled if the environment supports it + * (i.e., running in a TTY and the `NO_COLOR` environment variable is not set). + * + * When `false` or not provided, colorized output is disabled. + * + * When enabled, the following tokens will be color-coded in the output: + * `%status`, `%method`, `%duration`, `%durationPretty`, `%contentLength`, and `%contentLengthPretty`. * * @default false */ @@ -60,8 +65,11 @@ export function logger(options: LoggerOptions = {}): Middleware { let { format = '[%date] %method %path %status %durationPretty %contentLengthPretty', log = console.log, - colors = false, + colors, } = options + let useColor = + typeof process !== 'undefined' && process.stdout?.isTTY && process.env.NO_COLOR == null + let colorizer = new Colorizer(useColor && colors) return async ({ request, url }, next) => { let start = new Date() @@ -74,23 +82,18 @@ export function logger(options: LoggerOptions = {}): Middleware { let tokens: Record string> = { date: () => formatApacheDate(start), dateISO: () => start.toISOString(), - 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 - }, + duration: () => colorizer.duration(duration, String(duration)), + durationPretty: () => colorizer.duration(duration, formatDuration(duration)), + contentLength: () => colorizer.contentLength(contentLengthNum, contentLength ?? '-'), + contentLengthPretty: () => + colorizer.contentLength( + contentLengthNum, + contentLength ? formatFileSize(contentLengthNum!) : '-', + ), contentType: () => response.headers.get('Content-Type') ?? '-', host: () => url.host, hostname: () => url.hostname, - method: () => { - let value = request.method - return colors ? colorizeToken('method', value) : value - }, + method: () => colorizer.method(request.method), path: () => url.pathname + url.search, pathname: () => url.pathname, port: () => url.port, @@ -98,17 +101,13 @@ export function logger(options: LoggerOptions = {}): Middleware { query: () => url.search, referer: () => request.headers.get('Referer') ?? '-', search: () => url.search, - status: () => { - let value = String(response.status) - return colors ? colorizeToken('status', value, response.status) : value - }, + status: () => colorizer.status(response.status), statusText: () => response.statusText, url: () => url.href, userAgent: () => request.headers.get('User-Agent') ?? '-', } let message = format.replace(/%(\w+)/g, (_, key) => tokens[key]?.() ?? '-') - log(message) return response @@ -129,7 +128,6 @@ function formatApacheDate(date: Date): string { let minutes = String(date.getMinutes()).padStart(2, '0') let seconds = String(date.getSeconds()).padStart(2, '0') - // Get timezone offset in minutes and convert to ±HHMM format let timezoneOffset = date.getTimezoneOffset() let sign = timezoneOffset <= 0 ? '+' : '-' let offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0')