@logux/server
Version:
Build own Logux server or make proxy between WebSocket and HTTP backend on any language
305 lines (269 loc) • 8.29 kB
JavaScript
import { once } from 'node:events'
import os from 'node:os'
import { Transform } from 'node:stream'
import pico from 'picocolors'
import pino from 'pino'
import abstractTransport from 'pino-abstract-transport'
import stripAnsi from 'strip-ansi'
import { mulberry32, onceXmur3 } from './utils.js'
const INDENT = ' '
const PADDING = ' '
const SEPARATOR = os.EOL + os.EOL
const NEXT_LINE = os.EOL === '\n' ? '\r\v' : os.EOL
const PARAMS_BLACKLIST = {
component: true,
err: true,
hint: true,
hostname: true,
level: true,
listen: true,
msg: true,
name: true,
note: true,
pid: true,
server: true,
time: true,
v: true
}
const LABELS = {
30: (c, str) => label(c, ' INFO ', 'green', 'bgGreen', 'black', str),
40: (c, str) => label(c, ' WARN ', 'yellow', 'bgYellow', 'black', str),
50: (c, str) => label(c, ' ERROR ', 'red', 'bgRed', 'white', str),
60: (c, str) => label(c, ' FATAL ', 'red', 'bgRed', 'white', str)
}
const COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan']
function formatNow() {
let date = new Date()
let year = date.getFullYear()
let month = String(date.getMonth() + 1).padStart(2, '0')
let day = String(date.getDate()).padStart(2, '0')
let hour = String(date.getHours()).padStart(2, '0')
let minutes = String(date.getMinutes()).padStart(2, '0')
let seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}`
}
function rightPag(str, length) {
let add = length - stripAnsi(str).length
for (let i = 0; i < add; i++) str += ' '
return str
}
function label(c, type, color, labelBg, labelText, message) {
let pagged = rightPag(c[labelBg](c[labelText](type)), 8)
let time = c.dim(`at ${formatNow()}`)
let highlighted = message.replace(/`([^`]+)`/g, c.yellow('$1'))
return `${pagged}${c.bold(c[color](highlighted))} ${time}`
}
function formatName(key) {
return key
.replace(/[A-Z]/g, char => ` ${char.toLowerCase()}`)
.split(' ')
.map(word => (word === 'ip' || word === 'id' ? word.toUpperCase() : word))
.join(' ')
.replace(/^\w/, char => char.toUpperCase())
}
function shuffledColors(str) {
let index = -1
let result = Array.from(COLORS)
let lastIndex = result.length - 1
let seed = onceXmur3(str)
let randomFn = mulberry32(seed)
while (++index < COLORS.length) {
let randIndex = index + Math.floor(randomFn() * (lastIndex - index + 1))
let value = result[randIndex]
result[randIndex] = result[index]
result[index] = value
}
return result
}
function splitAndColorize(c, partLength, str) {
let strBuilder = []
let colors = shuffledColors(str)
for (
let start = 0, end = partLength, n = 0, color = colors[n];
start < str.length;
start += partLength,
end += partLength,
n = n + 1,
color = colors[n % colors.length]
) {
let strToColorize = str.slice(start, end)
if (strToColorize.length === 1) {
color = colors[Math.abs(n - 1) % colors.length]
}
strBuilder.push(c[color](strToColorize))
}
return strBuilder.join('')
}
function formatNodeId(c, nodeId) {
let pos = nodeId.lastIndexOf(':')
if (pos === -1) {
return nodeId
} else {
let s = nodeId.split(':')
let id = c.bold(s[0])
let random = splitAndColorize(c, 3, s[1])
return `${id}:${random}`
}
}
function formatValue(c, value) {
if (typeof value === 'string') {
return '"' + c.bold(value) + '"'
} else if (Array.isArray(value)) {
return formatArray(c, value)
} else if (typeof value === 'object' && value) {
return formatObject(c, value)
} else {
return c.bold(value)
}
}
function formatObject(c, obj) {
let items = Object.keys(obj).map(k => `${k}: ${formatValue(c, obj[k])}`)
return '{ ' + items.join(', ') + ' }'
}
function formatArray(c, array) {
let items = array.map(i => formatValue(c, i))
return '[' + items.join(', ') + ']'
}
function formatActionId(c, id) {
let p = id.split(' ')
if (p.length === 1) {
return p
}
return `${c.bold(splitAndColorize(c, 4, p[0]))} ${formatNodeId(
c,
p[1]
)} ${c.bold(p[2])}`
}
function formatParams(c, params, parent) {
let maxName = params.reduce((max, param) => {
let name = param[0]
return name.length > max ? name.length : max
}, 0)
return params
.map(param => {
let name = param[0]
let value = param[1]
let start = PADDING + rightPag(`${name}: `, maxName + 2)
if (name === 'Node ID' || (parent === 'Meta' && name === 'server')) {
return start + formatNodeId(c, value)
} else if (
parent === 'Meta' &&
(name === 'clients' || name === 'excludeClients')
) {
return `${start}[${value.map(v => `"${formatNodeId(c, v)}"`).join()}]`
} else if (name === 'Action ID' || (parent === 'Meta' && name === 'id')) {
return start + formatActionId(c, value)
} else if (Array.isArray(value)) {
return start + formatArray(c, value)
} else if (typeof value === 'object' && value) {
let nested = Object.keys(value).map(key => [key, value[key]])
return (
start +
NEXT_LINE +
INDENT +
formatParams(c, nested, name)
.split(NEXT_LINE)
.join(NEXT_LINE + INDENT)
)
} else if (typeof value === 'string' && parent) {
return start + '"' + c.bold(value) + '"'
} else {
return start + c.bold(value)
}
})
.join(NEXT_LINE)
}
function splitByLength(string, max) {
let words = string.split(' ')
let lines = ['']
for (let word of words) {
let last = lines[lines.length - 1]
if (last.length + word.length > max) {
lines.push(`${word} `)
} else {
lines[lines.length - 1] = `${last}${word} `
}
}
return lines.map(i => i.trim())
}
function prettyStackTrace(c, stack, basepath) {
return stack
.split('\n')
.slice(1)
.map(line => {
let match = line.match(/\s+at ([^(]+) \(([^)]+)\)/)
let isSystem = !match || !match[2].startsWith(basepath)
if (isSystem) {
return c.gray(line.replace(/^\s*/, PADDING))
} else {
let func = match[1]
let relative = match[2].slice(basepath.length)
let converted = `${PADDING}at ${func} (./${relative})`
let isDependency = match[2].includes('node_modules')
return isDependency ? c.gray(converted) : c.red(converted)
}
})
.join(NEXT_LINE)
}
function humanFormatter(options) {
let c = pico.createColors(options.color)
let basepath = options.basepath
return function format(record) {
let message = [LABELS[record.level](c, record.msg)]
let params = Object.keys(record)
.filter(key => !PARAMS_BLACKLIST[key])
.map(key => [formatName(key), record[key]])
if (record.loguxServer) {
params.unshift(['PID', record.pid])
if (record.server) {
params.push(['Listen', 'Custom HTTP server'])
} else {
params.push(['Listen', record.listen])
}
}
if (record.err && record.err.stack) {
message.push(prettyStackTrace(c, record.err.stack, basepath))
}
message.push(formatParams(c, params))
if (record.note) {
let note = record.note
if (typeof note === 'string') {
note = note.replace(/`([^`]+)`/g, c.bold('$1'))
note = [].concat(
...note
.split('\n')
.map(row => splitByLength(row, 80 - PADDING.length))
)
}
message.push(note.map(i => PADDING + c.gray(i)).join(NEXT_LINE))
}
return message.filter(i => i !== '').join(NEXT_LINE) + SEPARATOR
}
}
export default async function (options) {
let format = humanFormatter(options)
let destination = pino.destination({
dest: options.destination || 1,
sync: options.sync || false
})
await once(destination, 'ready')
let transform = new Transform({
autoDestroy: true,
objectMode: true,
transform(chunk, encoding, callback) {
callback(null, format(chunk))
}
})
return abstractTransport(
source => {
source.pipe(transform)
transform.pipe(destination)
},
{
close(err, cb) {
transform.end()
transform.on('close', cb.bind(null, err))
}
}
)
}