Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/logger-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions packages/logger-middleware/src/lib/colors.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
101 changes: 101 additions & 0 deletions packages/logger-middleware/src/lib/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
})
})
76 changes: 70 additions & 6 deletions packages/logger-middleware/src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Middleware } from '@remix-run/fetch-router'
import { colorizeToken } from './colors.ts'

/**
* Options for the `logger` middleware.
Expand All @@ -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
Expand All @@ -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
/**
Expand All @@ -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
}

/**
Expand All @@ -46,30 +57,51 @@ 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, () => 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,
protocol: () => url.protocol,
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') ?? '-',
Expand Down Expand Up @@ -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`
}
Loading