@steve02081504/virtual-console
Version:
A virtual console for capturing and manipulating terminal output.
528 lines (490 loc) • 19.4 kB
JavaScript
import { AsyncLocalStorage } from 'node:async_hooks'
import { Console } from 'node:console'
import process from 'node:process'
import { Writable } from 'node:stream'
import ansiEscapes from 'ansi-escapes'
import supportsAnsi from 'supports-ansi'
import { newLogEntry } from '../../core/entries.mjs'
import { unregisterExpandRefsForEntry } from '../../core/snapshot.mjs'
import { getStackInfo } from '../../core/stack.mjs'
import {
createGlobalConsoleProxy,
PASSTHROUGH_CONSOLE_METHODS,
RECORDABLE_CONSOLE_METHODS,
VIRTUAL_CONSOLE_ENTRY_STACK_SKIP,
} from '../common.mjs'
import { VirtualStream } from './virtual-stream.mjs'
/**
* Node 运行时:`VirtualConsole`、`AsyncLocalStorage` 与全局 `console` 代理(与 {@link ../browser/browser-console.mjs} 对称)。
*/
/**
* 未被代理的标准输出/错误输出流。
* @type {import('node:stream').Writable}
*/
const { stdout, stderr } = process
/**
* 全局异步存储,用于管理控制台上下文。
*/
export const consoleAsyncStorage = new AsyncLocalStorage()
/**
* 创建一个虚拟控制台,用于捕获输出,同时可以选择性地将输出传递给真实的控制台。
* @augments {Console}
*/
export class VirtualConsole extends Console {
/**
* 在新的异步上下文中执行fn,并将该上下文的控制台替换为此对象。
* 这是通过 Node.js 的 AsyncLocalStorage 实现的。
* @template T
* @overload
* @param {() => T | Promise<T>} fn - 在新的异步上下文中执行的函数。
* @returns {Promise<T>} 返回 fn 函数的 Promise 结果。
*/
/**
* 将当前“异步上下文”中的控制台替换为此对象。
* @overload
* @returns {void}
*/
/**
* 若提供fn,则在新的异步上下文中执行fn,并将fn上下文的控制台替换为此对象。
* 否则,将当前异步上下文中的控制台替换为此对象。
* @template T - fn 函数的返回类型。
* @param {(() => T | Promise<T>) | undefined} [fn] - 在新的异步上下文中执行的函数。
* @returns {Promise<T> | void} 若提供fn,则返回 fn 函数的 Promise 结果;否则返回void。
*/
hookAsyncContext(fn) {
if (fn) return runWithActiveConsole(this, fn)
else setActiveConsole(this)
}
/**
* 采集调用栈时额外跳过的帧数;初始为 `0`。
* 在自定义包装函数中调用 `console.*` 时,在调用前 `+1`,`finally` 中 `-1`,
* 以确保 `entry.stack` 指向真正的调用方而非包装层。
*/
stackFrameSkipCount = 0
/**
* 所有捕获输出拼接成的纯文本字符串。
* @returns {string} 聚合文本。
*/
get outputs() { return this.outputEntries.join('') }
/**
* 所有捕获输出拼接成的 HTML 字符串。
* @returns {string} 聚合 HTML。
*/
get outputsHtml() {
return this.outputEntries.map(entry => entry.toHtml()).join('')
}
/**
* 结构化日志条目数组。
* @type {import('../../core/entries.mjs').LogEntry[]}
*/
outputEntries = []
/**
* 日志条目监听器集合。
* @private @type {Set<(entry: import('../../core/entries.mjs').LogEntry) => void>}
*/
#logEntryListeners = new Set()
/**
* 缓冲清空后触发的监听器(无参数)。
* @private @type {Set<() => void>}
*/
#clearListeners = new Set()
/**
* 最终合并后的配置项(日志监听请用 {@link addLogEntryListener} / {@link removeLogEntryListener})。
* @type {object}
*/
options
/**
* `realConsoleOutput` 的透传目标控制台实例。
* @type {Console}
*/
#baseConsole
/**
* 包装后的标准输出流。
* @private @type {VirtualStream}
*/
#virtualStdout
/**
* 包装后的标准错误流。
* @private @type {VirtualStream}
*/
#virtualStderr
/**
* `freshLine` 上次使用的 id,用于 ANSI 覆盖同一行。
* @private @type {string | null}
*/
#lastFreshLineId = null
/**
* 供 `VirtualStream` 写入路径共享的状态与回调。
* @private @type {object}
*/
#streamContext
/**
* 创建 Node 侧虚拟控制台,并挂接 `AsyncLocalStorage` 隔离与虚拟标准流。
* @param {object} [options={}] - 配置选项。
* @param {boolean} [options.realConsoleOutput=false] - 为 true 时,捕获输出的同时也将其透传给底层控制台进行实际输出。
* @param {boolean} [options.recordOutput=true] - 为 false 时不记录任何条目(透传仍按配置执行)。
* @param {boolean} [options.supportsAnsi] - 为 true 时启用 ANSI:`freshLine` 可在 TTY 上覆盖行,`trace` 栈可含 OSC 8 超链接。未指定时自动检测;`baseConsole` 为 `VirtualConsole` 时继承其设置。
* @param {Console} [options.baseConsole] - `realConsoleOutput` 的透传目标。未指定时使用当前上下文的活动控制台。
* @param {number} [options.maxLogEntries=Infinity] - 最多保留的条目数,超出后自动丢弃最旧的条目。
*/
constructor(options = {}) {
super(new Writable({ /** 啥也不干 */ write: () => { } }), new Writable({ /** 啥也不干 */ write: () => { } }))
for (const property of ['_stdout', '_stderr'])
delete this[property] // 因为父类的实例属性会遮蔽子类的getter/setter,所以需要删除这些字段
const baseConsole = options.baseConsole ?? getActiveConsole()
delete options.baseConsole
this.options = {
realConsoleOutput: false,
recordOutput: true,
supportsAnsi: baseConsole.options?.supportsAnsi ?? supportsAnsi,
maxLogEntries: Infinity,
...options,
}
this.#streamContext = {
/**
* 写入发生前的回调函数,用于设置一些东西。
* @param {Buffer | string} chunk - 要写入的数据块。
* @param {string} encoding - 编码格式。
* @param {string} stream_name - 流名称。
* @returns {void}
*/
onWrite: (chunk, encoding, stream_name) => {
this.#lastFreshLineId = null
},
/**
* 在流写入路径中补录一条结构化日志。
* @param {string} method - 目标级别,通常为 stdout/stderr。
* @param {any[]} args - 日志参数数组,按 LogEntry 约定存储。
* @param {import('../../shared.d.mts').StackFrame[] | undefined} [stack] - 可选预采集栈;未传时由 #addEntry 自动采集。
* @returns {import('../../core/entries.mjs').LogEntry} 已写入缓冲区的日志条目。
*/
addEntry: (method, args, stack) => this.#addEntry(method, args, stack),
options: this.options,
state: this
}
this.baseConsole = baseConsole
for (const method of [
'freshLine', 'clear', 'writeAs',
'addLogEntryListener', 'removeLogEntryListener',
'addClearListener', 'removeClearListener'
])
this[method] = this[method].bind(this)
for (const method of RECORDABLE_CONSOLE_METHODS) {
if (!this[method]) continue
const originalMethod = this[method]
/**
* 将控制台方法重写为捕获输出并根据配置决定是否传递给底层控制台。
* @param {...any} args - 控制台方法的参数。
* @returns {void}
*/
this[method] = (...args) => {
const record = this.options.recordOutput
try {
if (record) {
this.#addEntry(method, args)
this.options.recordOutput = false // 避免stream写入时被重复记录
}
if (!this.options.realConsoleOutput) return originalMethod.apply(this, args)
this.#lastFreshLineId = null
try {
if (this.#baseConsole instanceof VirtualConsole) this.#baseConsole.stackFrameSkipCount++
return this.#baseConsole[method](...args)
} finally {
if (this.#baseConsole instanceof VirtualConsole) this.#baseConsole.stackFrameSkipCount--
}
} finally {
if (record) this.options.recordOutput = true
}
}
}
for (const method of PASSTHROUGH_CONSOLE_METHODS) {
if (!this[method]) continue
const originalMethod = this[method]
/**
* 透传到 Node `Console` 或基类:可选 `realConsoleOutput`。
* @param {...any} args - 透传参数。
* @returns {unknown} 底层方法返回值。
*/
this[method] = (...args) => {
if (!this.options.realConsoleOutput) return originalMethod.apply(this, args)
this.#lastFreshLineId = null
try {
if (this.#baseConsole instanceof VirtualConsole) this.#baseConsole.stackFrameSkipCount++
return this.#baseConsole[method](...args)
} finally {
if (this.#baseConsole instanceof VirtualConsole) this.#baseConsole.stackFrameSkipCount--
}
}
}
}
/**
* 创建新的日志条目。
* @param {string} method - 日志级别,例如 log/warn/error/stdout/stderr。
* @param {any[]} [args = []] - 与 console/stream 路径一致的原始参数数组。
* @param {import('../../shared.d.mts').StackFrame[] | undefined} [stack] - 可选预采集调用栈;未传时按当前 skip 配置自动采集。
* @returns {import('../../core/entries.mjs').LogEntry} 新的日志条目对象。
*/
#newLogEntry(method, args = [], stack = getStackInfo(this.stackFrameSkipCount + VIRTUAL_CONSOLE_ENTRY_STACK_SKIP)) {
return newLogEntry({ method, args, stack, supportsAnsi: this.options.supportsAnsi })
}
/**
* 创建日志条目并追加到 outputEntries,自动维护上限并触发回调。
* @param {string} method - 日志级别,例如 log/warn/error/stdout/stderr。
* @param {any[]} [args = []] - 与 console/stream 路径一致的原始参数数组。
* @param {import('../../shared.d.mts').StackFrame[] | undefined} [stack] - 可选预采集调用栈;未传时按当前 skip 配置自动采集。
* @returns {import('../../core/entries.mjs').LogEntry} 已写入缓冲区的日志条目对象。
*/
#addEntry(method, args = [], stack = getStackInfo(this.stackFrameSkipCount + VIRTUAL_CONSOLE_ENTRY_STACK_SKIP)) {
return this.#pushEntry(this.#newLogEntry(method, args, stack))
}
/**
* 将已构建的条目推入 outputEntries,维护上限并触发回调。
* @template {import('../../core/entries.mjs').LogEntry} T
* @param {T} entry - 已构造完成的日志条目实例。
* @returns {T} 原样返回该条目,便于调用侧继续链式使用或断言。
*/
#pushEntry(entry) {
this.outputEntries.push(entry)
if (this.outputEntries.length > this.options.maxLogEntries) {
const removed = this.outputEntries.shift()
if (removed) unregisterExpandRefsForEntry(removed)
}
for (const listener of this.#logEntryListeners) try {
listener(entry)
} catch { }
return entry
}
/**
* 注册新日志条目回调(可多路订阅)。
* @param {(entry: import('../../core/entries.mjs').LogEntry) => void} fn - 每条结构化日志写入缓冲后同步调用;勿假设异步顺序。
* @returns {void}
*/
addLogEntryListener(fn) {
this.#logEntryListeners.add(fn)
}
/**
* 取消先前通过 {@link addLogEntryListener} 注册的回调(引用相等时才生效)。
* @param {(entry: import('../../core/entries.mjs').LogEntry) => void} fn - 与注册时传入的函数同一引用。
* @returns {void}
*/
removeLogEntryListener(fn) {
this.#logEntryListeners.delete(fn)
}
/**
* 注册缓冲清空回调(`clear()` 在清空条目并可选调用底层 `clear()` 之后同步调用)。
* @param {() => void} fn - 回调。
* @returns {void}
*/
addClearListener(fn) {
this.#clearListeners.add(fn)
}
/**
* 取消先前通过 {@link addClearListener} 注册的回调。
* @param {() => void} fn - 与注册时同一引用。
* @returns {void}
*/
removeClearListener(fn) {
this.#clearListeners.delete(fn)
}
/**
* 与 Node 内置 `Console` 相同属性名;基类在输出时读此字段。实现委托 {@link #virtualStdout},勿误当作「下划线私有」习惯用法。
* @returns {VirtualStream} 标准输出流。
*/
get _stdout() {
return this.#virtualStdout
}
/**
* 设置标准输出流,自动将其包装为 VirtualStream。
* @param {import('node:stream').Writable | VirtualStream} value - 要设置的流。
* @returns {void}
*/
set _stdout(value) {
const context = this.#streamContext
const targetStream = value?.targetStream || value || stdout
this.#virtualStdout = new VirtualStream(targetStream, 'stdout', context)
}
/**
* 与 Node 内置 `Console` 相同属性名;实现委托 {@link #virtualStderr}。
* @returns {VirtualStream} 标准错误流。
*/
get _stderr() {
return this.#virtualStderr
}
/**
* 设置标准错误流,自动将其包装为 VirtualStream。
* @param {import('node:stream').Writable | VirtualStream} value - 要设置的流。
* @returns {void}
*/
set _stderr(value) {
const context = this.#streamContext
const targetStream = value?.targetStream || value || stderr
this.#virtualStderr = new VirtualStream(targetStream, 'stderr', context)
}
/**
* 设置用于 realConsoleOutput 的底层控制台实例。
* @param {Console} value - 底层控制台实例。
* @returns {void}
*/
set baseConsole(value) {
this.#baseConsole = value || globalThis.console
this._stdout = this.#baseConsole?._stdout
this._stderr = this.#baseConsole?._stderr
}
/**
* 获取用于 realConsoleOutput 的底层控制台实例。
* @returns {Console} 底层控制台实例。
*/
get baseConsole() {
return this.#baseConsole
}
/**
* 打印一行进度信息。若前一次调用传入了相同的 `id`,则覆盖上一行而不是新增一行
* (需要 ANSI 支持;在不支持 ANSI 的环境中等同于普通 `log`)。
* @param {string} id - 标识可覆盖行的唯一键。
* @param {...any} args - 要打印的内容。
*/
freshLine(id, ...args) {
this.#addEntry('freshLine', [id, ...args])
const previousRecordOutput = this.options.recordOutput
try {
this.options.recordOutput = false
this.stackFrameSkipCount++ // freshLine 自身是额外一层,由 log wrapper 统一处理其余帧
if (this.#baseConsole instanceof VirtualConsole) this.#baseConsole.freshLine(id, ...args)
else {
if (this.options.supportsAnsi && this.#lastFreshLineId === id)
this._stdout.write(ansiEscapes.cursorUp(1) + ansiEscapes.eraseLine)
this.log(...args)
}
} finally {
this.stackFrameSkipCount--
this.options.recordOutput = previousRecordOutput
}
this.#lastFreshLineId = id
}
/**
* 清空 `outputEntries` 并重置 `freshLine` 状态。
* 若 `realConsoleOutput` 为 true,也会调用底层控制台的 `clear()`。
* 清空完成后会同步调用 {@link addClearListener} 注册的回调。
* @returns {void}
*/
clear() {
this.#lastFreshLineId = null
for (const entry of this.outputEntries)
unregisterExpandRefsForEntry(entry)
this.outputEntries.length = 0
if (this.options.realConsoleOutput)
this.#baseConsole.clear()
for (const listener of this.#clearListeners) try {
listener()
} catch { }
}
/**
* 以指定级别记录日志,不经由 `console.*` 方法路由。
* 适合注入自定义级别的条目或在不触发其他副作用的情况下录入数据。
* 若 `realConsoleOutput` 为 true,warn/error/trace/stderr 类级别写入 stderr,其余写入 stdout。
* @param {string} method - 日志方法名。
* @param {...any} args - 要记录的内容。
* @returns {void}
*/
writeAs(method, ...args) {
const entry = this.#newLogEntry(method, args)
if (this.options.recordOutput) this.#pushEntry(entry)
if (this.options.realConsoleOutput)
if (this.#baseConsole instanceof VirtualConsole) this.#baseConsole.writeAs(method, ...args)
else {
const content = entry.toString()
const prevRecord = this.options.recordOutput
this.options.recordOutput = false
try {
if (['warn', 'error', 'trace', 'stderr'].includes(method)) return this._stderr.write(content)
else return this._stdout.write(content)
} finally {
this.options.recordOutput = prevRecord
}
}
}
}
const originalConsole = globalThis.console
/**
* 始终在线的兜底控制台:不记录任何条目,直接将所有输出透传到原始全局 `console`。
*/
export const defaultConsole = new VirtualConsole({ baseConsole: originalConsole, recordOutput: false, realConsoleOutput: true })
/**
* 合并到全局 `console` 代理上的附加属性对象。
* 对 `globalThis.console` 写入未知属性时,值存储在这里,以便跨异步上下文共享自定义扩展字段。
*/
export const globalConsoleAdditionalProperties = {}
/**
* 从 `consoleAsyncStorage` 读取当前活动控制台(无存储时回退 {@link defaultConsole})。
* @type {() => VirtualConsole}
*/
let getActiveConsole = () => consoleAsyncStorage.getStore() ?? defaultConsole
/**
* 将当前异步上下文绑定到指定控制台实例(`enterWith`,无自动还原)。
* @type {(value: VirtualConsole) => void}
*/
let setActiveConsole = (value) => consoleAsyncStorage.enterWith(value)
/**
* 在 `consoleAsyncStorage.run` 包裹的上下文中执行回调。
* @template T - fn 函数的返回类型
* @type {(value: VirtualConsole, fn: () => T) => Promise<T>}
*/
let runWithActiveConsole = (value, fn) => consoleAsyncStorage.run(value, fn)
/**
* 替换全局 `console` 代理的上下文路由逻辑。
* @template T
* @param {(defaultConsole: VirtualConsole) => VirtualConsole} resolveWithFallback 给定兜底值,返回当前应激活的控制台。
* @param {(value: VirtualConsole) => void} setActive 将指定实例设为当前上下文的活动控制台。
* @param {(value: VirtualConsole, callback: () => T) => Promise<T>} runInContext 在以指定实例为活动控制台的新上下文中执行回调。
* @returns {void}
*/
export function setGlobalConsoleResolver(resolveWithFallback, setActive, runInContext) {
/**
* 当前异步/全局上下文中应接收 `console` 调用的实例
* @returns {VirtualConsole} 当前异步上下文中应接收 `console` 调用的实例
*/
getActiveConsole = () => resolveWithFallback(defaultConsole)
setActiveConsole = setActive
runWithActiveConsole = runInContext
}
/**
* 读取当前的全局 `console` 代理路由逻辑。
* @returns {{ getActiveConsole: () => VirtualConsole, setActiveConsole: (value: VirtualConsole) => void, runWithActiveConsole: <T>(value: VirtualConsole, fn: () => T) => Promise<T> }} 当前生效的三段回调,可与 {@link setGlobalConsoleResolver} 配合替换或观测。
*/
export function getGlobalConsoleResolver() {
return {
getActiveConsole,
setActiveConsole,
runWithActiveConsole,
}
}
/**
* 全局控制台实例。
*/
export const console = globalThis.console = createGlobalConsoleProxy({
getActiveConsole,
originalConsole,
globalConsoleAdditionalProperties,
})
/**
* 重定向 process.stdout 到当前全局控制台的 `VirtualConsole#_stdout`(由 {@link VirtualConsole} 的 getter 暴露的虚拟流)。
*/
Object.defineProperty(process, 'stdout', {
/**
* 返回当前异步上下文绑定的虚拟 stdout。
* @returns {VirtualStream} 当前上下文中的虚拟标准输出流。
*/
get: () => getActiveConsole()._stdout,
configurable: true,
})
/**
* 重定向 process.stderr 到当前全局控制台的 `VirtualConsole#_stderr`。
*/
Object.defineProperty(process, 'stderr', {
/**
* 返回当前异步上下文绑定的虚拟 stderr。
* @returns {VirtualStream} 当前上下文中的虚拟标准错误流。
*/
get: () => getActiveConsole()._stderr,
configurable: true,
})