@steve02081504/virtual-console
Version:
A virtual console for capturing and manipulating terminal output.
192 lines (181 loc) • 8.56 kB
JavaScript
// 在非浏览器环境(Node.js / Deno 等)中加载 fs/url 以正确解析 file:// 路径;浏览器中降级为简单实现
/**
* 默认 realpath 实现:在不支持 node:fs 的环境中保持原样返回。
* @param {string} path - 待规范化的文件路径。
* @returns {string} 规范化后的路径;在降级实现中等于输入值。
*/
let realpathSync = path => path
/**
* 默认 fileURLToPath 实现:在不支持 node:url 的环境中尽量解析 URL pathname。
* @param {string} path - `file://` URL 或普通路径字符串。
* @returns {string} 解析后的本地路径;解析失败时回退原字符串。
*/
let fileURLToPath = path => { try { return new URL(path).pathname } catch { return path } }
/**
* 默认 pathToFileURL 实现:将本地路径转换为 file:// URL 字符串,用于构造可点击链接。
* 在 node:url 可用时会替换为原生实现,以正确处理 Windows 盘符等边界情况。
* @param {string} path - 本地文件路径。
* @returns {string} 对应的 file:// URL 字符串。
*/
export let pathToFileURL = path => {
if (!path || path.includes('://')) return path
const normalized = path.replace(/\\/g, '/')
const winDrive = /^([A-Za-z]):(\/|$)/.exec(normalized)
if (winDrive) {
const rest = normalized.slice(3)
const encodedRest = rest ? rest.split('/').map(encodeURIComponent).join('/') : ''
return `file:///${winDrive[1]}:/${encodedRest}`
}
const base = normalized.startsWith('/') ? 'file://' : 'file:///'
return base + normalized.split('/').map(encodeURIComponent).join('/')
}
if (!globalThis.document) await Promise.all([
import('node:fs').then(m => { realpathSync = m.realpathSync }),
import('node:url').then(m => {
fileURLToPath = m.fileURLToPath
/**
* 使用 Node 原生 `url.pathToFileURL` 生成含正确编码的 `href`。
* @param {string} p - 本地文件路径。
* @returns {string} 对应的 file:// URL 字符串。
*/
pathToFileURL = p => m.pathToFileURL(p).href
}),
]).catch(() => { })
/**
* 将栈帧路径段解析为可链接的本地路径;不可解析时返回空串(不抛错)。
* @param {string} filePath - 正则捕获的路径段。
* @returns {string} 规范化后的本地路径,或空串。
*/
function resolveStackFilePath(filePath) {
if (/^eval\b/.test(filePath) || filePath === '<anonymous>') return ''
if (filePath.startsWith('file://')) try {
return realpathSync(fileURLToPath(filePath))
} catch {
return ''
}
return filePath
}
/**
* 解析单行 stack 文本,规则与 {@link getStackInfo} 一致(用于 Error 栈着色 / OSC8)。
* @param {string} line - V8 等引擎输出的单行栈帧。
* @returns {import('../shared.d.mts').StackFrame} 解析失败时仅含 `raw`,路径字段为空。
*/
export function parseStackTraceLine(line) {
const evalAtMatch = line.match(/at\s+(?<functionName>\S+)\s+\(eval at\s+(?<hostName>\S+)\s+\((?<hostPath>[^)]+):(?<hostLine>\d+):(?<hostCol>\d+)\),\s*<anonymous>:(?<line>\d+):(?<column>\d+)\)$/)
if (evalAtMatch?.groups) {
const { functionName, hostPath, hostLine, hostCol, line: lineStr, column } = evalAtMatch.groups
/** @type {import('../shared.d.mts').StackFrame} */
const result = { functionName, filePath: '', line: Number(lineStr), column: Number(column), raw: line }
const resolved = resolveStackFilePath(hostPath)
if (resolved) {
result.filePath = resolved
result.line = Number(hostLine)
result.column = Number(hostCol)
}
return result
}
const match = line.match(/at\s+(?:(?<functionName>.*)\s+)?\((?<filePath>.*?):(?<line>\d+):(?<column>\d+)\)?$/) ||
line.match(/(?:(?<functionName>.*)\s+)?@(?<filePath>.*?):(?<line>\d+):(?<column>\d+)$/) ||
line.match(/at\s+(?<filePath>\S+):(?<line>\d+):(?<column>\d+)$/)
/** @type {import('../shared.d.mts').StackFrame} */
const result = {
functionName: '',
filePath: '',
line: 0,
column: 0,
raw: line,
}
if (!match?.groups) return result
const { functionName, filePath, line: lineStr, column } = match.groups
if (functionName !== undefined) result.functionName = functionName
if (filePath) {
result.filePath = resolveStackFilePath(filePath)
result.line = Number(lineStr)
result.column = Number(column)
}
return result
}
/**
* 将原始 stack 字符串按与 {@link getStackInfo} / {@link parseErrorStack} 相同的规则切片后逐行解析。
* @param {string} stackStr - `error.stack`。
* @param {number} leadingLinesToSkip - 在应用 runtime 额外跳过之前,从顶部丢弃的行数。
* @returns {import('../shared.d.mts').StackFrame[]} 解析得到的帧序列。
*/
function parseStackStringToFrames(stackStr, leadingLinesToSkip) {
if (!stackStr) return []
const lines = stackStr.split('\n')
if (lines.length === 1 && lines[0] === '') return []
let skip = leadingLinesToSkip
if (globalThis.chrome || !globalThis.document) skip++
let diagnosticLinesEndIndex = lines.findIndex(line => !line)
if (diagnosticLinesEndIndex === -1) diagnosticLinesEndIndex = 0
else skip += diagnosticLinesEndIndex + 1
const stackLines = lines.slice(skip).filter(line => line.trim())
return stackLines.map(parseStackTraceLine)
}
/**
* 解析任意 `Error` 的栈:先丢弃 `skipNum` 行,再按 runtime(Chrome / Node / Deno 等)多跳过一行(通常为 `Error:` 标题行),与 {@link getStackInfo} 同源。
* @param {unknown} error - 含 `stack` 的对象(一般为 `Error`)。
* @param {number} [skipNum=0] - 额外跳过的顶行数(不含 runtime 那一行)。
* @returns {import('../shared.d.mts').StackFrame[]} 错误栈帧数组。
*/
export function parseErrorStack(error, skipNum = 0) {
return parseStackStringToFrames(error?.stack, skipNum)
}
/**
* 是否适合生成可点击 OSC8 `href`(与 trace 栈一致:`http(s)`、`file:`、常见本地路径)。
* @param {string} filePath - 解析出的路径段。
* @returns {boolean} 可生成 OSC8 / file URL 时为 true。
*/
export function isLinkableStackPath(filePath) {
if (!filePath) return false
if (filePath.startsWith('node:')) return false
if (/^https?:\/\//.test(filePath)) return true
if (filePath.startsWith('file://')) return true
if (/^eval/.test(filePath)) return false
if (/^[/\\]/.test(filePath)) return true
if (/^[A-Za-z]:[/\\]/.test(filePath)) return true
return false
}
/**
* 由解析栈帧得到 OSC8 目标 URL;不可链接时返回空串。
* @param {import('../shared.d.mts').StackFrame} frame - {@link parseStackTraceLine} 产物。
* @returns {string} 链接触点的 URL;不可链时为空串。
*/
export function stackFrameToOsc8Href(frame) {
if (!frame?.filePath || !(frame.line > 0)) return ''
const fp = frame.filePath
if (!isLinkableStackPath(fp)) return ''
if (/^https?:\/\//.test(fp)) return `${fp}:${frame.line}:${frame.column}`
return `${pathToFileURL(fp)}:${frame.line}:${frame.column}`
}
/**
* 获取当前执行点的调用栈信息,并按 `leadingLinesToSkip` 跳过 stack 字符串顶部的若干行。
* `skip = 0` 表示仅应用运行时默认规则(见 {@link parseStackStringToFrames} 内 Chrome/Node 额外跳行),
* 不从「调用者」起算隐式偏移; larger skip 值显式丢弃更多顶行。
* @param {number} [leadingLinesToSkip=0] - 在 {@link parseStackStringToFrames} 规则之前从 `error.stack` 顶部丢弃的行数。
* @returns {StackFrame[]} 解析后的栈帧数组;若运行时不提供 stack 则返回空数组。
*/
export function getStackInfo(leadingLinesToSkip = 0) {
const error = new Error()
return parseStackStringToFrames(error.stack, leadingLinesToSkip)
}
/**
* 判断路径是否像 Node 嵌入的运行时栈帧(非用户源码)。
* @param {string} filePath - `StackFrame.filePath`
* @returns {boolean} 若路径疑似 Node/Deno 运行时内部帧(如 `node:internal/...`)则为 true。
*/
function isRuntimeInternalFilePath(filePath) {
return /^node:/.test(filePath)
}
/**
* 去掉栈顶连续的 Node 运行时内部帧(如 `node:internal/...`、`node:_stream_writable`、`deno:`)。
* 用于 `process.stdout`/`stderr` 写入:流实现会在用户调用之上插入多层运行时栈。
* @param {StackFrame[]} frames - `getStackInfo` 解析结果
* @returns {StackFrame[]} 去掉栈顶连续运行时内部帧后的数组(无非内部帧时原样切片)。
*/
export function trimLeadingRuntimeInternalFrames(frames) {
let index = frames.findIndex(f => !isRuntimeInternalFilePath(f.filePath))
if (index === -1) index = 0
return frames.slice(index)
}