UNPKG

@steve02081504/virtual-console

Version:

A virtual console for capturing and manipulating terminal output.

237 lines (222 loc) 9.04 kB
/** * 与任意 WebSocket 复用的日志线路消息(纯函数,不创建连接)。 */ import { expandSnapshotRef } from '../core/snapshot.mjs' import { logWirePayloadTypes, WS_OPEN } from './protocol.mjs' /** * @param {Set<{ readyState: number, send: (data: string) => void }>} clients - 当前客户端集合。 * @param {string} text - 已 `JSON.stringify` 的帧正文。 * @returns {void} */ function broadcastToOpen(clients, text) { for (const ws of clients) if (ws.readyState === WS_OPEN) try { ws.send(text) } catch { /* ignore send failure */ } } /** * 处理客户端发来的展开请求,返回应 `ws.send(JSON.stringify(...))` 的应答对象。 * @param {unknown} parsed - 客户端 JSON 负载。 * @param {{ expandSnapshotRef?: typeof expandSnapshotRef }} [handlers] - 可注入自定义展开实现(默认 `expandSnapshotRef`)。 * @returns {{ type: string, ref: string, ok: boolean, snapshot?: import('../shared.d.mts').ArgSnapshot, error?: string } | null} 非展开消息返回 `null`。 */ export function handleClientWireMessage(parsed, handlers = {}) { const expand = handlers.expandSnapshotRef ?? expandSnapshotRef const message = /** @type {Record<string, unknown>} */ parsed if (message.type !== logWirePayloadTypes.EXPAND_REQUEST) return null const { ref } = message const expandResult = expand(ref, message.maxDepth) if (expandResult.ok) return { type: logWirePayloadTypes.EXPAND_RESULT, ref, ok: true, snapshot: /** @type {import('../shared.d.mts').ArgSnapshot} */ expandResult.snapshot, } return { type: logWirePayloadTypes.EXPAND_RESULT, ref, ok: false, error: String(expandResult.error || 'expand_failed'), } } /** * 为 `VirtualConsole`(或兼容对象)创建可挂到 `express-ws` 等框架的 WebSocket 回调。 * 首次调用时注册一条 `addLogEntryListener`,向当前所有已连接客户端广播 `vc_log_append`。 * * @param {{ * outputEntries: import('../core/entries.mjs').LogEntry[] * addLogEntryListener: (fn: (entry: import('../core/entries.mjs').LogEntry) => void) => void * addClearListener: (fn: () => void) => void * removeLogEntryListener?: (fn: (entry: import('../core/entries.mjs').LogEntry) => void) => void * removeClearListener?: (fn: () => void) => void * clear: () => void * }} virtualConsole - 带缓冲与监听器的宿主控制台(通常为 `VirtualConsole`)。 * @param {{ * onClientConnected?: (event: { ws: unknown, req: unknown, clientCount: number }) => void | Promise<void> * onClientDisconnected?: (event: { ws: unknown, reason: 'close' | 'error', clientCount: number }) => void | Promise<void> * onClientMessage?: (event: { ws: unknown, req: unknown, message: Record<string, unknown>, clientCount: number }) => object | null | void | Promise<object | null | void> * clientMessageHandlers?: Record<string, (event: { ws: unknown, req: unknown, message: Record<string, unknown>, clientCount: number }) => object | null | void | Promise<object | null | void>> * }} [wireOptions] - 连接生命周期钩子与自定义上行消息处理器(仅处理非内置 `expand/clear` 请求)。 * @returns {(ws: { readyState: number, send: (data: string) => void, on: (ev: string, fn: (...args: unknown[]) => void) => void, close?: (code?: number, reason?: string) => void }, req?: unknown) => void) & { * broadcastJson: (payload: object) => void, * forEachClient: (fn: (ws: unknown) => void) => void, * closeAllWithFinalJson: (payload: object) => Promise<void>, * dispose: () => void, * }} 供挂载的 `(ws, req) => void`,并附带 `broadcastJson` / `forEachClient` / `closeAllWithFinalJson` 控制面。 */ export function createLogWireWebSocketHandler(virtualConsole, wireOptions = {}) { const { onClientConnected, onClientDisconnected, onClientMessage, clientMessageHandlers = {}, } = wireOptions const clients = new Set() /** * 将新日志条目广播为 `vc_log_append`。 * @param {import('../core/entries.mjs').LogEntry} entry - 宿主新增的日志条目。 * @returns {void} */ const onLogEntry = (entry) => { const payload = JSON.stringify({ type: logWirePayloadTypes.APPEND, entry: entry.toJSON(), }) broadcastToOpen(clients, payload) } /** * 将 clear 事件广播为 `vc_log_cleared`。 * @returns {void} */ const onClear = () => { broadcastToOpen(clients, JSON.stringify({ type: logWirePayloadTypes.CLEARED })) } virtualConsole.addLogEntryListener(onLogEntry) virtualConsole.addClearListener(onClear) /** * 显式托管 Promise 拒绝,避免形成未处理 reject。 * @param {void | Promise<void>} maybePromise - 可能的异步结果。 * @returns {void} */ function absorbAsyncError(maybePromise) { void Promise.resolve(maybePromise).catch(() => { }) } /** * @param {{ readyState: number, send: (data: string) => void, on: (ev: string, fn: (...args: unknown[]) => void) => void, close?: (code?: number, reason?: string) => void }} ws - WebSocket 兼容连接。 * @param {unknown} [req] - 升级请求(若有)。 */ const handler = (ws, req) => { /** * 发送 JSON 回包(当自定义消息处理器返回对象时使用)。 * @param {object | null | void} maybePayload - 可能的回包对象。 * @returns {void} */ function sendCustomReply(maybePayload) { if (maybePayload) try { ws.send(JSON.stringify(maybePayload)) } catch { /* ignore send failure */ } } /** * 分发自定义上行消息;先命中 `clientMessageHandlers[type]`,再走 `onClientMessage` 兜底。 * 两者若返回对象都会作为 JSON 回包发回当前连接。 * @param {Record<string, unknown>} message - 客户端上行 JSON。 * @returns {Promise<void>} */ function dispatchCustomClientMessage(message) { const event = { ws, req, message, clientCount: clients.size } const messageType = message.type if (clientMessageHandlers[messageType]) absorbAsyncError(Promise.resolve(clientMessageHandlers[messageType](event)).then(sendCustomReply)) else absorbAsyncError(Promise.resolve(onClientMessage?.(event)).then(sendCustomReply)) } clients.add(ws) sendCustomReply({ type: logWirePayloadTypes.SNAPSHOT, entries: virtualConsole.outputEntries.map(entry => entry.toJSON()), }) absorbAsyncError(onClientConnected?.({ ws, req, clientCount: clients.size, })) ws.on('message', (raw) => { let parsed try { parsed = JSON.parse(String(raw)) } catch { return } const wireReply = handleClientWireMessage(parsed) if (wireReply) sendCustomReply(wireReply) else { const message = /** @type {Record<string, unknown>} */ parsed if (message.type === logWirePayloadTypes.CLEAR_REQUEST) virtualConsole.clear() else dispatchCustomClientMessage(message) } }) ws.on('error', () => { if (clients.delete(ws)) absorbAsyncError(onClientDisconnected?.({ ws, reason: 'error', clientCount: clients.size, })) }) ws.on('close', () => { if (clients.delete(ws)) absorbAsyncError(onClientDisconnected?.({ ws, reason: 'close', clientCount: clients.size, })) }) } /** * 向所有当前连接且处于 OPEN 的客户端发送同一 JSON 正文(不经缓冲区),用于宿主自定义扩展帧。 * @param {object} payload - 可 `JSON.stringify` 的对象。 * @returns {void} */ handler.broadcastJson = (payload) => { const text = JSON.stringify(payload) for (const ws of clients) if (ws.readyState === WS_OPEN) try { ws.send(text) } catch { /* ignore send failure */ } } /** * 遍历当前仍登记的客户端套接字(可能非 OPEN);用于宿主自定义广播或计量。 * @param {(ws: { readyState: number, send: (data: string) => void, on: (ev: string, fn: (...args: unknown[]) => void) => void, close: (code?: number, reason?: string) => void }) => void} fn - 同步回调。 * @returns {void} */ handler.forEachClient = (fn) => { for (const ws of clients) fn(ws) } /** * 向每个仍处于 OPEN 的连接发送最终 JSON,随后 `close()`,并等待全部触发 `close`(已关闭的套接字会立刻计入完成)。 * @param {object} payload - 可 `JSON.stringify` 的对象。 * @returns {Promise<void>} 全部 `close` 回调结算后兑现。 */ handler.closeAllWithFinalJson = async (payload) => { const text = JSON.stringify(payload) /** @type {Promise<void>[]} */ const closeWaits = [] for (const ws of [...clients]) try { if (ws.readyState === WS_OPEN) ws.send(text) closeWaits.push(new Promise((resolve) => { if (ws.readyState !== WS_OPEN) resolve() else ws.once('close', resolve) })) if (ws.readyState === WS_OPEN) ws.close() } catch { /* ignore */ } await Promise.allSettled(closeWaits) } /** * 移除所有注册的监听器。 */ handler.dispose = () => { virtualConsole.removeLogEntryListener?.(onLogEntry) virtualConsole.removeClearListener?.(onClear) } return handler }