UNPKG

@steve02081504/virtual-console

Version:

A virtual console for capturing and manipulating terminal output.

507 lines (466 loc) 19.8 kB
import { parseErrorStack } from './stack.mjs' /** * 检测值是否为 Proxy(可用时使用 `util.types.isProxy`,否则恒为 `false`)。 * @param {unknown} value - 待检测的值。 * @returns {boolean} 当 `value` 为 Proxy 实例时为 `true`。 */ let isProxyInstance = (value) => false await import('node:util/types').then(module => { const candidate = module.isProxy // 浏览器垫片(如 esm.sh/unenv)会把未实现的 isProxy 做成“调用即抛错”的桩函数, // import() 本身不抛错,故需实际探测一次;不可用时维持恒 false 回退而非直接采用。 if (globalThis.document) candidate({}) isProxyInstance = candidate }).catch(() => 0) /** 参数快照默认深度(log / dir / 线路一致) */ export const DEFAULT_SNAPSHOT_DEPTH = 5 /** * 从结构化片段解析主调用点:优先首个根级 `Error` 快照栈中带路径的帧,否则回退到捕获栈。 * 与 {@link serializeArgSnapshot} 产出的 Error 快照字段对齐;进程内 {@link LogEntry#toSegments} 与线路 `segments` 共用。 * @param {import('../shared.d.mts').LogSegment[] | undefined} segments - 结构化片段。 * @param {import('../shared.d.mts').StackFrame[]} [stack] - `getStackInfo` 捕获栈。 * @returns {import('../shared.d.mts').StackFrame | null} */ export function resolvePrimaryCallsiteFromSegments(segments, stack) { for (const seg of segments ?? []) if (seg?.kind === 'value' && seg?.snapshot?.kind === 'Error') return seg.snapshot.stack.find(f => f?.filePath) ?? null return stack?.find(f => f?.filePath) ?? null } /** * 惰性展开 ref → 弱引用条目与强引用截断对象。 * @type {Map<string, { weakEntryRef: WeakRef<object>, strongTarget: object }>} */ const expandRegistry = new Map() /** * 条目 → 该条目注册过的展开 ref 集合(便于淘汰时清理)。 * @type {WeakMap<object, Set<string>>} */ const entryToExpandRefs = new WeakMap() /** * `LogEntry` 被 GC 且未走缓冲挤出时,清理 {@link expandRegistry} 中残留的 ref→强引用槽位。 * @type {FinalizationRegistry<Set<string>>} */ const expandEntryFinalizer = new FinalizationRegistry((refs) => { if (!refs) return for (const ref of refs) expandRegistry.delete(ref) }) /** * 为深度截断处的对象注册惰性展开槽位。 * @param {object} entry - 所属日志条目,用于在淘汰时成批清理 ref。 * @param {object} strongTarget - 截断边界对象(强引用钉住直至展开或条目淘汰)。 * @returns {string} 客户端请求展开时使用的不透明 `ref`。 */ function registerExpandSlot(entry, strongTarget) { const ref = globalThis.crypto?.randomUUID?.() || `r_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}` expandRegistry.set(ref, { weakEntryRef: new WeakRef(entry), strongTarget, }) let set = entryToExpandRefs.get(entry) if (!set) { set = new Set() entryToExpandRefs.set(entry, set) expandEntryFinalizer.register(entry, set) } set.add(ref) return ref } /** * 条目被丢弃或清空时释放展开注册(避免强引用泄漏)。 * @param {object} entry - 即将移出缓冲区的日志条目。 * @returns {void} */ export function unregisterExpandRefsForEntry(entry) { expandEntryFinalizer.unregister(entry) const set = entryToExpandRefs.get(entry) if (!set) return for (const ref of set) expandRegistry.delete(ref) entryToExpandRefs.delete(entry) } /** * 为单次序列化构造「可分配展开 ref」的上下文(与 {@link LogEntry} 生命周期绑定)。 * @param {object} entry - 当前正在序列化的日志条目。 * @returns {{ allocRef: (t: object) => string }} 提供 `allocRef` 以在截断处注册强引用目标。 */ export function createExpansionScope(entry) { return { /** * 在深度边界为对象注册可展开槽位并返回不透明 ref。 * @param {object} target - 被截断替换为占位符的对象引用。 * @returns {string} 新注册的展开 ref。 */ allocRef(target) { return registerExpandSlot(entry, target) }, } } /** * 为截断占位生成人类可读标签(数组长度、Map size、构造名等)。 * @param {object} value - 被截断的对象值。 * @returns {string} 简短类型描述,用于 UI 折叠展示。 */ function truncationLabel(value) { if (Array.isArray(value)) return `Array(${value.length})` const tag = Object.prototype.toString.call(value) if (tag === '[object Map]') return `Map(${value.size})` if (tag === '[object Set]') return `Set(${value.size})` if (tag === '[object Error]') return value.name || 'Error' const name = value?.constructor?.name if (name && name !== 'Object') return name return 'Object' } /** * 在达到 `maxDepth` 时将对象折叠为 `truncated` 节点,可选注册展开槽。 * @param {object} value - 当前深度的对象值。 * @param {ReturnType<typeof createExpansionScope> | null} expansionScope - 若有则在对象上分配 ref;否则返回空 ref 占位。 * @returns {import('../shared.d.mts').ArgSnapshotTruncated} 始终为 `kind: 'truncated'` 的快照片段。 */ function truncateOrPlaceholder(value, expansionScope) { if (expansionScope && value !== null && typeof value === 'object') { const ref = expansionScope.allocRef(/** @type {object} */ value) return { kind: 'truncated', ref, label: truncationLabel(value) } } return { kind: 'truncated', ref: '', label: truncationLabel(value) } } /** * @typedef {object} SerializeArgSnapshotOptions * @property {number} [maxDepth=DEFAULT_SNAPSHOT_DEPTH] * @property {ReturnType<typeof createExpansionScope> | null} [expansionScope=null] */ /** * @typedef {object} SerializeWalkContext * @property {object[]} seenStack - 当前 DFS 路径上的对象(与 Node `util.inspect` 的 `ctx.seen` 一致;非栈内重复不算环)。 * @property {Map<object, number> | undefined} circularRefs - 作为 `[Circular *N]` / `<ref *N>` 目标的对象 → 编号。 * @property {number} maxDepth * @property {ReturnType<typeof createExpansionScope> | null} expansionScope */ /** * 为出现在环上的对象分配稳定编号(与 Node `util.inspect` 的 `ctx.circular` 一致)。 * @param {SerializeWalkContext} walkContext - 序列化上下文。 * @param {object} targetObject - 背边指向的目标对象。 * @returns {number} 从 1 起的编号。 */ function assignCircularRefIndex(walkContext, targetObject) { if (!walkContext.circularRefs) walkContext.circularRefs = new Map() const map = walkContext.circularRefs let index = map.get(targetObject) if (index !== undefined) return index index = map.size + 1 map.set(targetObject, index) return index } /** * 若该对象被登记为环目标,则在快照上附带 `inspectRefId`(供 `<ref *N>` 前缀)。 * @param {import('../shared.d.mts').ArgSnapshot} snap - 刚生成的快照。 * @param {object} valueObject - 与 `snap` 对应的原始对象引用。 * @param {SerializeWalkContext} walkContext - 序列化上下文。 * @returns {import('../shared.d.mts').ArgSnapshot} 与输入同结构的快照;若该对象在环上则多一个 `inspectRefId` 数字字段,否则原样返回。 */ function attachInspectRefIfNeeded(snap, valueObject, walkContext) { const inspectRefIndex = walkContext.circularRefs?.get(valueObject) if (inspectRefIndex === undefined) return snap if (snap === null || typeof snap !== 'object' || Array.isArray(snap)) return snap return /** @type {import('../shared.d.mts').ArgSnapshot} */ { ...snap, inspectRefId: inspectRefIndex } } /** * 按 ref 展开深层快照(成功后释放该 ref 的强引用)。 * @param {string} ref - 客户端自 `truncated.ref` 取得的标识。 * @param {number} [maxDepth=DEFAULT_SNAPSHOT_DEPTH] - 展开时再序列化的最大深度。 * @returns {{ ok: true, snapshot: import('../shared.d.mts').ArgSnapshot } | { ok: false, error: string }} 成功带完整快照,失败带机器可读 `error` 码。 */ export function expandSnapshotRef(ref, maxDepth = DEFAULT_SNAPSHOT_DEPTH) { const slot = expandRegistry.get(ref) if (!slot) return { ok: false, error: 'unknown_ref' } const entry = slot.weakEntryRef.deref() if (!entry) { expandRegistry.delete(ref) return { ok: false, error: 'entry_gone' } } const { strongTarget } = slot expandRegistry.delete(ref) const refsSet = entryToExpandRefs.get(entry) refsSet?.delete(ref) if (refsSet && refsSet.size === 0) entryToExpandRefs.delete(entry) const nestedScope = createExpansionScope(entry) try { const snapshot = serializeArgSnapshot(strongTarget, { maxDepth, expansionScope: nestedScope }) return { ok: true, snapshot } } catch (error) { return { ok: false, error: String(error?.message || error) } } } /** * 将非对象原语序列化为带 `kind` 的叶子节点。 * @param {unknown} value - 原始值。 * @param {string} valueType - `typeof value`。 * @returns {import('../shared.d.mts').ArgSnapshot} 叶子快照片段。 */ function snapshotPrimitive(value, valueType) { if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') return { kind: valueType, value } if (valueType === 'undefined') return { kind: 'undefined', value: 'undefined' } if (valueType === 'bigint') return { kind: 'bigint', value: /** @type {bigint} */ value.toString() } if (valueType === 'symbol') return { kind: 'symbol', value: /** @type {symbol} */ value.toString() } if (valueType === 'function') { let isClass = false try { isClass = /^\s*class[\s{]/.test(Function.prototype.toString.call(value)) } catch { isClass = false } return { kind: 'function', value: /** @type {Function} */ value.name || '(anonymous)', isClass } } return { kind: 'unknown', value: String(value) } } /** * 读取自有数据/访问器属性用于快照:数据属性用 `[[GetOwnProperty]]` 的 `value`,避免 Proxy 的 `get` 陷阱掩盖真实引用(与 Node `util.inspect` 一致)。 * 访问器属性仍调用 getter。 * @param {object} hostObject - 对象或 Proxy(无 `getOwnPropertyDescriptor` 陷阱时转目标)。 * @param {string} key - 属性名。 * @returns {unknown} 数据属性的快照值、访问器调用 getter 的结果,或回退/缺失时为 `undefined`。 */ function getOwnPropertySnapshotValue(hostObject, key) { const descriptor = Reflect.getOwnPropertyDescriptor(hostObject, key) if (!descriptor) try { return /** @type {Record<string, unknown>} */ hostObject[key] } catch { return undefined } if ('value' in descriptor) return descriptor.value if (typeof descriptor.get === 'function') return descriptor.get.call(hostObject) return undefined } /** * `candidate` 是否与 `proxy` 在自有键及描述符可见取值上一致(透明转发 Proxy 的常见目标识别)。 * @param {object} proxy - Proxy 实例。 * @param {object} candidate - 候选目标。 * @returns {boolean} 当自有键集合一致且各键经描述符可见取值相等时为 `true`(视为透明转发目标)。 */ function matchesTransparentProxyTarget(proxy, candidate) { if (proxy === candidate) return false const pKeys = Object.keys(proxy) if (Object.keys(candidate).length !== pKeys.length) return false for (const key of pKeys) if (getOwnPropertySnapshotValue(proxy, key) !== getOwnPropertySnapshotValue(candidate, key)) return false return true } /** * 无 native `getProxyDetails` 时,用描述符图推断透明 Proxy 的目标(与 Node `util.inspect` 默认解包行为对齐)。 * @param {object} proxy - Proxy。 * @returns {object | undefined} 唯一可确定的转发目标;不确定则 `undefined`。 */ function tryResolveTransparentProxyTarget(proxy) { if (!isProxyInstance(proxy)) return undefined const pKeys = Object.keys(proxy) /** @type {object | undefined} */ let found for (const key of pKeys) { const val = getOwnPropertySnapshotValue(proxy, key) if (val === null || typeof val !== 'object') continue if (!matchesTransparentProxyTarget(proxy, /** @type {object} */ val)) continue if (found !== undefined && found !== val) return undefined found = /** @type {object} */ val } return found } /** * 反复解析透明 Proxy 链直至无法解析,使环检测 / `<ref *N>` 与 `util.inspect` 一样基于目标身份。 * @param {unknown} value - 任意对象引用。 * @returns {unknown} 解包后的对象或原值。 */ function unwrapTransparentProxyChain(value) { let current = value while (current !== null && typeof current === 'object' && isProxyInstance(/** @type {object} */ current)) { const next = tryResolveTransparentProxyTarget(/** @type {object} */ current) if (next === undefined) break current = next } return current } /** * 单键转发 Proxy 且目标为「单键自环」对象时,与 Node `util.inspect` 一样改为序列化**目标**(避免 `get` 陷阱与多包一层结构)。 * @param {unknown} value - 任意值。 * @returns {object | undefined} 应直接走 `walk` 的目标对象;不展开时 `undefined`。 */ function tryUnwrapForwardingProxy(value) { if (value === null || typeof value !== 'object') return undefined if (!isProxyInstance(value)) return undefined const keys = Object.keys(/** @type {object} */ value) if (keys.length !== 1) return undefined const key = keys[0] const inner = getOwnPropertySnapshotValue(/** @type {object} */ value, key) if (inner === null || typeof inner !== 'object') return undefined const innerKeys = Object.keys(inner) if (innerKeys.length !== 1 || innerKeys[0] !== key) return undefined if (getOwnPropertySnapshotValue(inner, key) !== inner) return undefined return /** @type {object} */ inner } /** * 序列化对象的自有可枚举属性(按 `Object.keys` 顺序)。 * 读取属性值时使用快照安全读取,避免触发抛错中断整个序列化。 * @param {object} targetObject - 待收集属性的对象。 * @param {(child: unknown) => import('../shared.d.mts').ArgSnapshot} serializeProperty - 子值序列化函数。 * @returns {Array<{ key: string; value: import('../shared.d.mts').ArgSnapshot }>} 键值快照列表。 */ function collectOwnEntries(targetObject, serializeProperty) { const out = [] for (const key of Object.keys(targetObject)) out.push({ key, value: serializeProperty(getOwnPropertySnapshotValue(targetObject, key)) }) return out } /** * 按 `Object.prototype.toString` 标签分派对象/Error/容器等结构。 * @param {unknown} value - 当前值。 * @param {string} tag - `Object.prototype.toString.call` 类名,如 `[object Array]`。 * @param {number} depth - 当前深度。 * @param {SerializeWalkContext} walkContext - 环检测、深度与展开上下文。 * @param {(value: unknown, depth: number, walkContext: SerializeWalkContext) => import('../shared.d.mts').ArgSnapshot} walk - 递归步进。 * @returns {import('../shared.d.mts').ArgSnapshot} 子树快照。 */ function snapshotObjectByTag(value, tag, depth, walkContext, walk) { const { maxDepth, expansionScope } = walkContext /** * 对子值再走一层 `walk`。 * @param {unknown} child - 子属性或元素值。 * @returns {import('../shared.d.mts').ArgSnapshot} 子快照。 */ const serializeChild = child => walk(child, depth + 1, walkContext) if (depth >= maxDepth) return truncateOrPlaceholder(/** @type {object} */ value, expansionScope) if (tag === '[object Error]') { const err = /** @type {Error & Record<string, unknown>} */ value const entries = [] for (const key of Object.keys(err)) if (!['stack', 'message', 'name'].includes(key)) entries.push({ key, value: serializeChild(getOwnPropertySnapshotValue(err, key)) }) const stack = parseErrorStack(err) return { kind: 'Error', name: err.name, message: err.message, stack, entries, } } if (tag === '[object Date]') return { kind: 'Date', value: /** @type {Date} */ value.getTime() } if (tag === '[object RegExp]') return { kind: 'RegExp', value: /** @type {RegExp} */ value.toString() } if (tag === '[object Number]') { const boxedObject = /** @type {object} */ value const unboxed = Number(boxedObject) const entries = collectOwnEntries(boxedObject, serializeChild) const boxedText = Object.is(unboxed, -0) ? '-0' : String(unboxed) if (!entries.length) return { kind: 'Number', boxedText } return { kind: 'Number', boxedText, entries } } if (tag === '[object Boolean]') { const boxedObject = /** @type {object} */ value const unboxed = Boolean(boxedObject) const entries = collectOwnEntries(boxedObject, serializeChild) const boxedText = unboxed ? 'true' : 'false' if (!entries.length) return { kind: 'Boolean', boxedText } return { kind: 'Boolean', boxedText, entries } } if (tag === '[object String]') { const boxedObject = /** @type {object} */ value const unboxed = String(boxedObject) const entries = collectOwnEntries(boxedObject, serializeChild) if (!entries.length) return { kind: 'String', boxedString: unboxed } return { kind: 'String', boxedString: unboxed, entries } } if (tag === '[object Map]') { const map = /** @type {Map<unknown, unknown>} */ value return { kind: 'Map', items: [...map.entries()].map(([key, val]) => ({ key: serializeChild(key), value: serializeChild(val), })), } } if (tag === '[object Set]') { const set = /** @type {Set<unknown>} */ value return { kind: 'Set', items: [...set.values()].map(el => serializeChild(el)), } } if (Array.isArray(value)) return { kind: 'array', items: value.map(item => serializeChild(item)) } const obj = /** @type {object} */ value const entries = collectOwnEntries(obj, serializeChild) return { kind: obj.constructor?.name || 'object', entries, } } /** * 深度优先序列化入口:原语、环检测与对象分派。 * @param {unknown} value - 当前值。 * @param {number} depth - 从根算起的深度。 * @param {SerializeWalkContext} walkContext - 环检测、深度、展开。 * @returns {import('../shared.d.mts').ArgSnapshot} 根快照。 */ function walk(value, depth, walkContext) { if (value === null) return { kind: 'null', value: null } const valueType = typeof value if (valueType !== 'object') return snapshotPrimitive(value, valueType) const chainUnwrapped = unwrapTransparentProxyChain(value) if (chainUnwrapped !== value) return walk(chainUnwrapped, depth, walkContext) const proxyUnwrapped = tryUnwrapForwardingProxy(value) if (proxyUnwrapped !== undefined) return walk(proxyUnwrapped, depth, walkContext) const obj = /** @type {object} */ value const stack = walkContext.seenStack if (stack.includes(obj)) { const circularRefIndex = assignCircularRefIndex(walkContext, obj) return { kind: 'circular', refId: circularRefIndex } } stack.push(obj) try { const tag = Object.prototype.toString.call(value) let snap = snapshotObjectByTag(value, tag, depth, walkContext, walk) snap = attachInspectRefIfNeeded(snap, obj, walkContext) return snap } finally { stack.pop() } } /** * 将任意值序列化为可 JSON 传输的快照(与 `renderPlain(buildArgsSegments(…))` / DevTools 风格展示对齐)。 * @param {any} value - 原始值。 * @param {SerializeArgSnapshotOptions} [options] - `maxDepth`、`expansionScope` 等。 * @returns {import('../shared.d.mts').ArgSnapshot} 可 `JSON.stringify` 的快照树。 */ export function serializeArgSnapshot(value, options = {}) { const { maxDepth = DEFAULT_SNAPSHOT_DEPTH, expansionScope = null, } = options /** @type {SerializeWalkContext} */ const walkContext = { seenStack: [], circularRefs: undefined, maxDepth, expansionScope, } return walk(value, 0, walkContext) }