@steve02081504/virtual-console 0.0.8 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,43 +3,27 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@steve02081504/virtual-console.svg)](https://www.npmjs.com/package/@steve02081504/virtual-console)
4
4
  [![GitHub issues](https://img.shields.io/github/issues/steve02081504/virtual-console)](https://github.com/steve02081504/virtual-console/issues)
5
5
 
6
- A powerful and flexible virtual console for Node.js that allows you to capture, manipulate, and redirect terminal output. Built with modern asynchronous contexts (`AsyncLocalStorage`) for robust, concurrency-safe operations.
6
+ A powerful and flexible virtual console for **Node.js and the Browser** that allows you to capture, manipulate, redirect, and transform terminal output.
7
7
 
8
- `VirtualConsole` is perfect for:
8
+ `VirtualConsole` acts as a smart proxy for the global `console`, providing:
9
9
 
10
- - **Testing:** Assert console output from your modules without polluting the test runner's output.
11
- - **Logging Frameworks:** Create custom logging solutions that can buffer, format, or redirect logs.
12
- - **CLI Tools:** Build interactive command-line interfaces with updatable status lines or progress bars.
13
- - **Debugging:** Isolate and inspect output from specific parts of your application, even in highly concurrent scenarios.
14
-
15
- ---
16
-
17
- ## How It Works: The Global Console Proxy
18
-
19
- **This library is designed for zero-refactoring integration.** Upon import, it replaces `globalThis.console` with a smart proxy that is aware of the asynchronous execution context.
20
-
21
- 1. **The Proxy:** The new `console` object is a proxy. When you call a method like `console.log()`, the proxy intercepts the call.
22
-
23
- 2. **`AsyncLocalStorage` for Isolation:** The proxy uses `AsyncLocalStorage` to look up the currently active `VirtualConsole` instance for the current async operation.
24
-
25
- 3. **Context-Aware Routing:**
26
- - If you have activated a `VirtualConsole` instance using `vc.hookAsyncContext()`, the proxy finds your instance and routes all `console` calls to it. This allows your instance to capture output, handle stateful methods like `freshLine`, or apply custom logic.
27
- - If no instance is active for the current context, the proxy forwards the call to a default, passthrough console that simply prints to the original terminal, just like the real `console` would.
28
-
29
- This architecture means you **don't need to refactor your code to pass console instances around**. Just keep using the global `console` as you always have, and wrap the code you want to monitor in a hook.
30
-
31
- **Enhanced Compatibility:** The proxy is designed to be a good citizen. If other libraries or your own code assign custom properties to the global `console` object (e.g., `console.myLogger = ...`), these properties are preserved and remain accessible.
10
+ - **Async Context Isolation:** Safely capture output per request or task using `AsyncLocalStorage` (Node.js) or stack-based scoping (Browser).
11
+ - **HTML Output Generation:** Automatically converts ANSI colors and console formatting (including `%c`) into HTML strings for display in web UIs or reports.
12
+ - **Zero-Refactoring:** Works by proxying the global `console`, so you don't need to change your existing logging code.
32
13
 
33
14
  ## Features
34
15
 
35
- - **Zero-Configuration Capturing:** Capture output from any module without changing its source code.
36
- - **Concurrency-Safe Isolation:** Uses `AsyncLocalStorage` to guarantee that output from concurrent operations is captured independently and correctly.
37
- - **Output Recording:** Captures all `stdout` and `stderr` output to a string property for easy inspection.
38
- - **Real Console Passthrough:** Optionally, print to the actual console while also capturing.
39
- - **Full TTY Emulation:** Behaves like a real TTY, inheriting properties like `columns`, `rows`, and color support from the base console. It also respects terminal resize events.
40
- - **Updatable Lines (`freshLine`)**: A stateful method for creating overwritable lines, perfect for progress indicators. Requires a TTY with ANSI support.
41
- - **Custom Error Handling:** Provide a dedicated handler for `Error` objects passed to `console.error`.
42
- - **Extensible:** Can be integrated with other async-context-aware libraries via `setGlobalConsoleReflect`.
16
+ - **Universal Compatibility:** Works in both Node.js and Browser environments.
17
+ - **Output Recording:** Captures `stdout` and `stderr` to plain text (`outputs`) and **HTML** (`outputsHtml`).
18
+ - **ANSI & HTML Support:**
19
+ - Node.js: Preserves ANSI color codes.
20
+ - Browser/HTML: Converts ANSI codes and `%c` CSS styles to inline HTML styles.
21
+ - **Concurrency-Safe (Node.js):** Uses `AsyncLocalStorage` to guarantee that output from concurrent async operations is captured independently.
22
+ - **Real Console Passthrough:** Optionally prints to the actual console/terminal while capturing.
23
+ - **FreshLine (Updatable Lines):** Stateful method for creating overwritable lines (e.g., progress bars).
24
+ - *Node.js:* Uses ANSI escape codes to overwrite lines.
25
+ - *Browser:* Falls back gracefully to standard logging (simulated behavior).
26
+ - **Custom Error Handling:** Dedicated interception for `console.error(new Error(...))`.
43
27
 
44
28
  ## Installation
45
29
 
@@ -47,95 +31,82 @@ This architecture means you **don't need to refactor your code to pass console i
47
31
  npm install @steve02081504/virtual-console
48
32
  ```
49
33
 
50
- ## Quick Start: Testing Console Output
34
+ ## Usage
51
35
 
52
- The most common use case is testing. Wrap your function call in `hookAsyncContext` and then assert the captured output.
36
+ ### 1. Basic Testing (Capture Output)
37
+
38
+ Wrap your function call in `hookAsyncContext` and assert the captured output.
53
39
 
54
40
  ```javascript
55
41
  import { VirtualConsole } from '@steve02081504/virtual-console';
56
42
  import { strict as assert } from 'node:assert';
57
43
 
58
- // 1. A function that logs to the console
59
44
  function greet(name) {
60
- console.log(`Hello, ${name}!`);
61
- console.error('An example error.');
45
+ console.log(`Hello, ${name}!`);
46
+ console.error(new Error('Something broke'));
62
47
  }
63
48
 
64
- // 2. In your test:
65
- async function testGreeting() {
66
- const vc = new VirtualConsole();
49
+ async function test() {
50
+ const vc = new VirtualConsole();
67
51
 
68
- // 3. Run the function inside the hook to capture its output.
69
- // All `console.*` calls inside `greet` are now routed to `vc`.
70
- await vc.hookAsyncContext(() => greet('World'));
52
+ // Run inside the hook. All console calls are routed to 'vc'.
53
+ await vc.hookAsyncContext(() => greet('World'));
71
54
 
72
- // 4. Assert the captured output
73
- const expectedOutput = 'Hello, World!\nAn example error.\n';
74
- assert.strictEqual(vc.outputs, expectedOutput);
75
- console.log('Test passed!');
55
+ assert.ok(vc.outputs.includes('Hello, World!'));
56
+ assert.ok(vc.outputs.includes('Error: Something broke'));
76
57
  }
77
58
 
78
- testGreeting();
59
+ test();
79
60
  ```
80
61
 
81
- ## Advanced Usage
82
-
83
- ### Use Case: Concurrent Progress Bars with `freshLine`
62
+ ### 2. Generating HTML Output for Web UIs
84
63
 
85
- This example demonstrates the power of async context isolation. We run two progress updates concurrently. Each `hookAsyncContext` creates an isolated "session," ensuring that each `console.freshLine` call updates its own line without interfering with the other.
64
+ One of the most powerful features is `outputsHtml`, which converts console formatting to valid HTML string.
86
65
 
87
66
  ```javascript
88
67
  import { VirtualConsole } from '@steve02081504/virtual-console';
89
68
 
90
- const vc = new VirtualConsole({ realConsoleOutput: true });
69
+ const vc = new VirtualConsole();
91
70
 
92
- async function updateProgress(taskName, duration) {
93
- for (let i = 0; i <= 100; i += 20) {
94
- // `console.freshLine` is a stateful method. It works correctly here
95
- // because each task has its own isolated VirtualConsole state.
96
- console.freshLine(taskName, `[${taskName}]: ${i}%`);
97
- await new Promise(res => setTimeout(res, duration));
98
- }
99
- console.log(`[${taskName}]: Done!`);
100
- }
71
+ await vc.hookAsyncContext(() => {
72
+ // ANSI Colors (Node.js style)
73
+ console.log('\x1b[31mRed Text\x1b[0m');
74
+
75
+ // CSS Styling (Browser style - %c)
76
+ console.log('%cBig Blue Text', 'color: blue; font-size: 20px');
77
+
78
+ // Objects
79
+ console.log({ foo: 'bar' });
80
+ });
101
81
 
102
- console.log('Starting concurrent tasks...');
82
+ // Get the captured output as HTML
83
+ const html = vc.outputsHtml;
103
84
 
104
- // Run two tasks, each in its own isolated hook.
105
- // Without this isolation, they would conflict and corrupt the output.
106
- await Promise.all([
107
- vc.hookAsyncContext(() => updateProgress('Upload-A', 50)),
108
- vc.hookAsyncContext(() => updateProgress('Process-B', 75)),
109
- ]);
110
-
111
- console.log('All tasks finished!');
85
+ // Result example:
86
+ // <span style="color:rgb(170,0,0)">Red Text</span>
87
+ // <span style="color: blue; font-size: 20px">Big Blue Text</span>
88
+ // ...
112
89
  ```
113
90
 
114
- ### Use Case: Custom Error Handling
91
+ ### 3. Concurrent Tasks (Node.js)
115
92
 
116
- You can provide a dedicated `error_handler` to process `Error` objects passed to `console.error`. This is useful for integrating with logging services or custom error-reporting frameworks.
93
+ In Node.js, `VirtualConsole` uses `AsyncLocalStorage` to ensure logs from concurrent tasks don't mix.
117
94
 
118
95
  ```javascript
119
96
  import { VirtualConsole } from '@steve02081504/virtual-console';
120
97
 
121
- const reportedErrors = [];
122
-
123
- const vc = new VirtualConsole({
124
- // This handler is called ONLY when a single Error object is passed to console.error
125
- error_handler: (err) => {
126
- console.log(`Reporting error: "${err.message}"`);
127
- reportedErrors.push(err);
128
- },
129
- realConsoleOutput: true,
130
- });
98
+ const vc = new VirtualConsole({ realConsoleOutput: true });
131
99
 
132
- await vc.hookAsyncContext(() => {
133
- console.error(new Error('Something went wrong!')); // Handled by error_handler
134
- console.error('A regular error message.'); // Not an Error object, logged normally
135
- });
100
+ async function work(id, duration) {
101
+ console.log(`Starting task ${id}`); // Captured by the specific context
102
+ await new Promise(r => setTimeout(r, duration));
103
+ console.log(`Finished task ${id}`);
104
+ }
136
105
 
137
- // Verify the custom handler was called
138
- console.log('Reported errors:', reportedErrors.map(e => e.message));
106
+ await Promise.all([
107
+ vc.hookAsyncContext(() => work('A', 100)), // Captured in context A
108
+ vc.hookAsyncContext(() => work('B', 50)), // Captured in context B
109
+ ]);
139
110
  ```
140
111
 
141
112
  ## API Reference
@@ -145,46 +116,57 @@ console.log('Reported errors:', reportedErrors.map(e => e.message));
145
116
  Creates a new `VirtualConsole` instance.
146
117
 
147
118
  - `options` `<object>`
148
- - `realConsoleOutput` `<boolean>`: If `true`, output is also sent to the base console. **Default:** `false`.
149
- - `recordOutput` `<boolean>`: If `true`, output is captured in the `outputs` property. **Default:** `true`.
150
- - `base_console` `<Console>`: The console instance for passthrough. **Default:** The original `global.console` before it was patched.
151
- - `error_handler` `<function(Error): void>`: A dedicated handler for `Error` objects passed to `console.error`. If `console.error` is called with a single argument that is an `instanceof Error`, this handler is invoked instead of the standard `console.error` logic. **Default:** `null`.
152
- - `supportsAnsi` `<boolean>`: Manually set ANSI support, which affects `freshLine`. **Default:** Inherited from `base_console`'s stream, or the result of the `supports-ansi` package.
119
+ - `realConsoleOutput` `<boolean>`: If `true`, output is also sent to the base (real) console. **Default:** `false`.
120
+ - `recordOutput` `<boolean>`: If `true`, output is captured in `outputs` and `outputsHtml`. **Default:** `true`.
121
+ - `base_console` `<Console>`: The console instance to pass through to.
122
+ - `error_handler` `<function(Error): void>`: specific handler for `console.error(err)`.
123
+ - `supportsAnsi` `<boolean>`: Force enable/disable ANSI support (affects `freshLine`).
153
124
 
154
125
  ### `virtualConsole.hookAsyncContext(fn?)`
155
126
 
156
- Hooks the virtual console into an asynchronous context.
157
-
158
- - **`hookAsyncContext(fn)`**: Runs `fn` in a new context. All `console` calls within `fn` (and any functions it `await`s) are routed to this instance. Returns a `Promise` that resolves with the return value of `fn`.
159
- - **`hookAsyncContext()`**: Activates the instance for the *current* asynchronous context. Useful for "activating" a console and leaving it active for the remainder of an async flow (e.g., within middleware).
127
+ Hooks the virtual console into the current execution context.
160
128
 
161
- ### `virtualConsole.outputs`
129
+ - **`hookAsyncContext(fn)`**: Runs `fn` and routes all `console.*` calls inside it to this instance. Returns a `Promise` with the result of `fn`.
130
+ - **`hookAsyncContext()`**: (Advanced) Manually sets this instance as the active console for the current context.
162
131
 
163
- - `<string>`
132
+ ### Properties
164
133
 
165
- A string containing all captured `stdout` and `stderr` output.
134
+ - **`vc.outputs`** `<string>`: Captured raw text output (includes ANSI codes in Node.js).
135
+ - **`vc.outputsHtml`** `<string>`: Captured output converted to HTML strings. Handles ANSI codes and `%c` styling.
166
136
 
167
- ### `console.freshLine(id, ...args)`
137
+ ### Methods
168
138
 
169
- *Note: This stateful method is available on the global `console` object but only works as intended inside a `hookAsyncContext`.*
139
+ - **`console.freshLine(id, ...args)`**:
140
+ Prints a line that can be overwritten by subsequent calls with the same `id`. Useful for progress bars.
141
+ - *Node.js:* Uses ANSI cursor movements.
142
+ - *Browser:* Simulates behavior (appends new lines).
143
+ - **`vc.clear()`**: Clears `outputs` and `outputsHtml`.
144
+ - **`vc.error(err)`**: Custom error handling if configured.
170
145
 
171
- Prints a line. If the previously printed line (within the same hook) had the same `id`, it overwrites the previous line using ANSI escape codes. This requires a TTY environment.
146
+ ## Platform Differences
172
147
 
173
- - `id` `<string>`: A unique identifier for the overwritable line.
174
- - `...args` `<any>`: The content to print, same as `console.log()`.
148
+ ### Node.js
175
149
 
176
- ### `virtualConsole.clear()`
150
+ - Implementation relies on `node:async_hooks` (`AsyncLocalStorage`).
151
+ - Context isolation works perfectly even across `setTimeout`, `Promise`, and other async boundaries.
152
+ - `freshLine` supports real terminal cursor manipulation.
177
153
 
178
- Clears the captured `outputs` string. If `realConsoleOutput` is enabled, it also attempts to call `clear()` on the base console.
154
+ ### Browser
179
155
 
180
- ### `virtualConsole.error(...args)`
156
+ - Implementation relies on a global variable stack strategy.
157
+ - **Scope Limitation:** `hookAsyncContext(fn)` works for the duration of the function execution. However, strict "async" context propagation (like passing context into a `setTimeout` callback) is mimicked but may not be as robust as Node.js's native hooks.
158
+ - `freshLine` cannot erase previous lines in the real browser console limitations, so it appends logs instead.
181
159
 
182
- The standard `console.error` method. However, if an `error_handler` is configured in the constructor, and `error()` is called with a single argument that is an `instanceof Error`, the handler will be invoked with the error object.
160
+ ## Integration for Library Authors
183
161
 
184
- ## For Library Authors: `setGlobalConsoleReflect(...)`
162
+ If you are building a library that manages its own async contexts, you can synchronize with `VirtualConsole` using:
185
163
 
186
- `VirtualConsole` exposes its `AsyncLocalStorage`-based reflection mechanism. If you are building another library that also manages async context (e.g., a custom APM or transaction tracer), you can use `setGlobalConsoleReflect` to integrate `VirtualConsole`'s logic into your own context manager. This prevents the two libraries from fighting over control of the async context. See the source code for details on its function signature.
187
-
188
- ## Contributing
164
+ ```javascript
165
+ import { setGlobalConsoleReflect } from '@steve02081504/virtual-console';
189
166
 
190
- Contributions are welcome! Please open an issue or submit a pull request on [GitHub](https://github.com/steve02081504/virtual-console).
167
+ setGlobalConsoleReflect(
168
+ (defaultConsole) => { /* return active console */ },
169
+ (consoleInstance) => { /* set active console */ },
170
+ (consoleInstance, fn) => { /* run fn in context */ }
171
+ );
172
+ ```
package/browser.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { FullProxy } from 'full-proxy'
2
2
 
3
+ import { argsToHtml } from './util.mjs'
4
+
3
5
  /**
4
6
  * 存储原始的浏览器 console 对象。
5
7
  */
@@ -11,24 +13,85 @@ const originalConsole = window.console
11
13
  * @returns {string} 格式化后的单行字符串。
12
14
  */
13
15
  function formatArgs(args) {
14
- return args.map(arg => {
15
- if (Object(arg) instanceof String) return arg
16
- if (arg instanceof Error && arg.stack) return arg.stack
17
- try {
18
- return JSON.stringify(arg, null, '\t')
16
+ if (args.length === 0) return ''
17
+ const format = args[0]
18
+ if (format?.constructor !== String)
19
+ return args.map(arg => {
20
+ if (Object(arg) instanceof String) return arg
21
+ if (arg instanceof Error && arg.stack) return arg.stack
22
+ try {
23
+ return JSON.stringify(arg, null, '\t')
24
+ }
25
+ catch {
26
+ return String(arg)
27
+ }
28
+ }).join(' ')
29
+
30
+ let output = ''
31
+ let argIndex = 1
32
+ let lastIndex = 0
33
+ const regex = /%[sdifoOc%]/g
34
+ let match
35
+
36
+ while ((match = regex.exec(format)) !== null) {
37
+ output += format.slice(lastIndex, match.index)
38
+ lastIndex = regex.lastIndex
39
+
40
+ if (match[0] === '%%') {
41
+ output += '%'
42
+ continue
19
43
  }
20
- catch {
21
- return String(arg)
44
+
45
+ if (argIndex >= args.length) {
46
+ output += match[0]
47
+ continue
48
+ }
49
+
50
+ const arg = args[argIndex++]
51
+ switch (match[0]) {
52
+ case '%c':
53
+ break
54
+ case '%s':
55
+ output += String(arg)
56
+ break
57
+ case '%d':
58
+ case '%i':
59
+ output += String(parseInt(arg))
60
+ break
61
+ case '%f':
62
+ output += String(parseFloat(arg))
63
+ break
64
+ case '%o':
65
+ case '%O':
66
+ try { output += JSON.stringify(arg, null, '\t') }
67
+ catch { output += String(arg) }
68
+ break
22
69
  }
23
- }).join(' ')
70
+ }
71
+ output += format.slice(lastIndex)
72
+
73
+ while (argIndex < args.length) {
74
+ const arg = args[argIndex++]
75
+ if (output) output += ' '
76
+ if (arg instanceof Error && arg.stack) output += arg.stack
77
+ else if ((arg === null || arg instanceof Object) && !(arg instanceof Function))
78
+ try { output += JSON.stringify(arg, null, '\t') }
79
+ catch { output += String(arg) }
80
+
81
+ else output += String(arg)
82
+ }
83
+
84
+ return output
24
85
  }
25
86
 
26
87
  /**
27
- * 创建一个虚拟控制台,用于捕获输出,同时可以选择性地将输出传递给真实的浏览器控制台。
88
+ * 创建一个虚拟控制台,用于捕获输出,同时可以选择性地将输出传递给真实的控制台。
28
89
  */
29
90
  export class VirtualConsole {
30
91
  /** @type {string} - 捕获的所有输出 */
31
92
  outputs = ''
93
+ /** @type {string} - 捕获的所有输出 (HTML) */
94
+ outputsHtml = ''
32
95
 
33
96
  /** @type {object} - 最终合并后的配置项 */
34
97
  options
@@ -43,6 +106,7 @@ export class VirtualConsole {
43
106
  * @param {object} [options={}] - 配置选项。
44
107
  * @param {boolean} [options.realConsoleOutput=false] - 如果为 true,则在捕获输出的同时,也调用底层控制台进行实际输出。
45
108
  * @param {boolean} [options.recordOutput=true] - 如果为 true,则捕获输出并保存在 outputs 属性中。
109
+ * @param {function(Error): void} [options.error_handler=null] - 一个专门处理单个 Error 对象的错误处理器。
46
110
  * @param {Console} [options.base_console=window.console] - 用于 realConsoleOutput 的底层控制台实例。
47
111
  */
48
112
  constructor(options = {}) {
@@ -52,25 +116,32 @@ export class VirtualConsole {
52
116
  this.options = {
53
117
  realConsoleOutput: false,
54
118
  recordOutput: true,
119
+ error_handler: null,
55
120
  ...options,
56
121
  }
57
122
 
58
123
  const methods = ['log', 'info', 'warn', 'debug', 'error', 'table', 'dir', 'assert', 'count', 'countReset', 'time', 'timeLog', 'timeEnd', 'group', 'groupCollapsed', 'groupEnd']
59
124
  for (const method of methods)
60
- if (typeof this.#base_console[method] === 'function')
125
+ if (this.#base_console[method] instanceof Function)
126
+ /**
127
+ * 重写控制台方法
128
+ * @param {...any} args - 控制台方法的参数。
129
+ * @returns {void}
130
+ */
61
131
  this[method] = (...args) => {
132
+ if (method == 'error' && this.options.error_handler && args.length === 1 && args[0] instanceof Error) return this.options.error_handler(args[0])
62
133
  this.#loggedFreshLineId = null // 任何常规输出都会中断 freshLine 序列
63
134
 
64
- if (this.options.recordOutput)
135
+ if (this.options.recordOutput) {
65
136
  this.outputs += formatArgs(args) + '\n'
137
+ this.outputsHtml += argsToHtml(args) + '<br/>\n'
138
+ }
66
139
 
67
140
  // 实际输出
68
141
  if (this.options.realConsoleOutput)
69
142
  this.#base_console[method](...args)
70
143
  }
71
144
 
72
-
73
-
74
145
  this.freshLine = this.freshLine.bind(this)
75
146
  this.clear = this.clear.bind(this)
76
147
  }
@@ -92,8 +163,9 @@ export class VirtualConsole {
92
163
  /**
93
164
  * 若提供fn,则在新的异步上下文中执行fn,并将fn上下文的控制台替换为此对象。
94
165
  * 否则,将当前异步上下文中的控制台替换为此对象。
95
- * @param {(() => T | Promise<T>) | undefined} [fn]
96
- * @returns {Promise<T> | void}
166
+ * @template T - fn 函数的返回类型。
167
+ * @param {(() => T | Promise<T>) | undefined} [fn] - 在新的异步上下文中执行的函数。
168
+ * @returns {Promise<T> | void} 若提供fn,则返回 fn 函数的 Promise 结果;否则返回void。
97
169
  */
98
170
  hookAsyncContext(fn) {
99
171
  if (fn) return consoleReflectRun(this, fn)
@@ -118,23 +190,31 @@ export class VirtualConsole {
118
190
  }
119
191
 
120
192
  /**
121
- * 清空捕获的输出,并可以选择性地清空真实控制台。
193
+ * 清空捕获的输出,并选择性地清空真实控制台。
194
+ * @returns {void}
122
195
  */
123
196
  clear() {
124
197
  this.#loggedFreshLineId = null
125
198
  this.outputs = ''
199
+ this.outputsHtml = ''
126
200
  if (this.options.realConsoleOutput)
127
201
  this.#base_console.clear()
128
202
 
129
203
  }
130
204
  }
131
205
 
206
+ /**
207
+ * 默认的全局虚拟控制台实例。
208
+ */
132
209
  export const defaultConsole = new VirtualConsole({
133
210
  base_console: originalConsole,
134
211
  recordOutput: false,
135
212
  realConsoleOutput: true,
136
213
  })
137
214
 
215
+ /**
216
+ * 全局控制台的附加属性。
217
+ */
138
218
  export const globalConsoleAdditionalProperties = {}
139
219
 
140
220
  // 模拟 AsyncLocalStorage 的上下文存储
@@ -149,7 +229,7 @@ let consoleReflectSet = (v) => {
149
229
  }
150
230
 
151
231
  /**
152
- * @template T
232
+ * @template T - fn 函数的返回类型
153
233
  * @type {(value: VirtualConsole, fn: () => T | Promise<T>) => Promise<T>}
154
234
  */
155
235
  let consoleReflectRun = async (v, fn) => {
@@ -165,11 +245,27 @@ let consoleReflectRun = async (v, fn) => {
165
245
  }
166
246
 
167
247
  // 暴露设置和获取反射逻辑的函数,以完全匹配原始API
248
+ /**
249
+ * 设置全局控制台反射逻辑
250
+ * @template T - fn 函数的返回类型
251
+ * @param {(console: Console) => Console} Reflect 将 console 参数映射到新的 console 对象的函数。
252
+ * @param {(console: Console) => void} ReflectSet 设置当前 console 对象的函数。
253
+ * @param {(console: Console, fn: () => T) => Promise<T>} ReflectRun 在新的异步上下文中执行函数的函数。
254
+ * @returns {void}
255
+ */
168
256
  export function setGlobalConsoleReflect(Reflect, ReflectSet, ReflectRun) {
257
+ /**
258
+ * 从默认控制台获取当前控制台对象。
259
+ * @returns {Console} 当前控制台对象。
260
+ */
169
261
  consoleReflect = () => Reflect(defaultConsole)
170
262
  consoleReflectSet = ReflectSet
171
263
  consoleReflectRun = ReflectRun
172
264
  }
265
+ /**
266
+ * 获取全局控制台反射逻辑。
267
+ * @returns {object} 包含 Reflect、ReflectSet 和 ReflectRun 函数的对象。
268
+ */
173
269
  export function getGlobalConsoleReflect() {
174
270
  return {
175
271
  Reflect: consoleReflect,
@@ -183,6 +279,13 @@ export function getGlobalConsoleReflect() {
183
279
  * 这与原始 Node.js 版本的实现完全相同。
184
280
  */
185
281
  export const console = globalThis.console = new FullProxy(() => Object.assign({}, globalConsoleAdditionalProperties, consoleReflect()), {
282
+ /**
283
+ * 设置属性时的处理逻辑。
284
+ * @param {object} target - 目标对象。
285
+ * @param {string | symbol} property - 要设置的属性名。
286
+ * @param {any} value - 要设置的属性值。
287
+ * @returns {boolean} 指示属性是否成功设置的布尔值。
288
+ */
186
289
  set: (target, property, value) => {
187
290
  target = consoleReflect()
188
291
  if (property in target) return Reflect.set(target, property, value)
package/main.mjs CHANGED
@@ -1,9 +1,30 @@
1
1
  const module = await import(globalThis.document ? './browser.mjs' : './node.mjs')
2
2
 
3
+ /**
4
+ * @type {AsyncLocalStorage}
5
+ */
3
6
  export const consoleAsyncStorage = module.consoleAsyncStorage
7
+ /**
8
+ * @type {typeof VirtualConsole}
9
+ */
4
10
  export const VirtualConsole = module.VirtualConsole
11
+ /**
12
+ * @type {VirtualConsole}
13
+ */
5
14
  export const defaultConsole = module.defaultConsole
15
+ /**
16
+ * @type {object}
17
+ */
6
18
  export const globalConsoleAdditionalProperties = module.globalConsoleAdditionalProperties
19
+ /**
20
+ * @type {function}
21
+ */
7
22
  export const setGlobalConsoleReflect = module.setGlobalConsoleReflect
23
+ /**
24
+ * @type {function}
25
+ */
8
26
  export const getGlobalConsoleReflect = module.getGlobalConsoleReflect
27
+ /**
28
+ * @type {VirtualConsole}
29
+ */
9
30
  export const console = module.console
package/node.mjs CHANGED
@@ -7,6 +7,11 @@ import ansiEscapes from 'ansi-escapes'
7
7
  import { FullProxy } from 'full-proxy'
8
8
  import supportsAnsi from 'supports-ansi'
9
9
 
10
+ import { argsToHtml } from './util.mjs'
11
+
12
+ /**
13
+ * 全局异步存储,用于管理控制台上下文。
14
+ */
10
15
  export const consoleAsyncStorage = new AsyncLocalStorage()
11
16
  const cleanupRegistry = new FinalizationRegistry(cleanupToken => {
12
17
  const { stream, listener } = cleanupToken
@@ -15,27 +20,28 @@ const cleanupRegistry = new FinalizationRegistry(cleanupToken => {
15
20
 
16
21
  /**
17
22
  * 创建一个虚拟控制台,用于捕获输出,同时可以选择性地将输出传递给真实的控制台。
18
- *
19
- * @extends {Console}
23
+ * @augments {Console}
20
24
  */
21
25
  export class VirtualConsole extends Console {
22
26
  /**
23
- * 在新的Async上下文中执行fn,并将fn上下文的控制台替换为此对象。
27
+ * 在新的异步上下文中执行fn,并将该上下文的控制台替换为此对象。
28
+ * 这是通过 Node.js 的 AsyncLocalStorage 实现的。
24
29
  * @template T
25
30
  * @overload
26
- * @param {() => T} fn - 在新的Async上下文中执行的函数。
31
+ * @param {() => T | Promise<T>} fn - 在新的异步上下文中执行的函数。
27
32
  * @returns {Promise<T>} 返回 fn 函数的 Promise 结果。
28
33
  */
29
34
  /**
30
- * 将当前Async上下文中的控制台替换为此对象。
35
+ * 将当前“异步上下文”中的控制台替换为此对象。
31
36
  * @overload
32
37
  * @returns {void}
33
38
  */
34
39
  /**
35
- * 若提供fn,则在新的Async上下文中执行fn,并将fn上下文的控制台替换为此对象。
36
- * 否则,将当前Async上下文中的控制台替换为此对象。
37
- * @param {(() => T) | undefined} [fn]
38
- * @returns {Promise<T> | void}
40
+ * 若提供fn,则在新的异步上下文中执行fn,并将fn上下文的控制台替换为此对象。
41
+ * 否则,将当前异步上下文中的控制台替换为此对象。
42
+ * @template T - fn 函数的返回类型。
43
+ * @param {(() => T | Promise<T>) | undefined} [fn] - 在新的异步上下文中执行的函数。
44
+ * @returns {Promise<T> | void} 若提供fn,则返回 fn 函数的 Promise 结果;否则返回void。
39
45
  */
40
46
  hookAsyncContext(fn) {
41
47
  if (fn) return consoleReflectRun(this, fn)
@@ -43,6 +49,8 @@ export class VirtualConsole extends Console {
43
49
  }
44
50
  /** @type {string} - 捕获的所有输出 */
45
51
  outputs = ''
52
+ /** @type {string} - 捕获的所有输出 (HTML) */
53
+ outputsHtml = ''
46
54
 
47
55
  /** @type {object} - 最终合并后的配置项 */
48
56
  options
@@ -57,11 +65,12 @@ export class VirtualConsole extends Console {
57
65
  * @param {object} [options={}] - 配置选项。
58
66
  * @param {boolean} [options.realConsoleOutput=false] - 如果为 true,则在捕获输出的同时,也调用底层控制台进行实际输出。
59
67
  * @param {boolean} [options.recordOutput=true] - 如果为 true,则捕获输出并保存在 outputs 属性中。
68
+ * @param {boolean} [options.supportsAnsi] - 如果为 true, 则启用 ANSI 转义序列支持。
60
69
  * @param {function(Error): void} [options.error_handler=null] - 一个专门处理单个 Error 对象的错误处理器。
61
70
  * @param {Console} [options.base_console=console] - 用于 realConsoleOutput 的底层控制台实例。
62
71
  */
63
72
  constructor(options = {}) {
64
- super(new Writable({ write: () => { } }), new Writable({ write: () => { } }))
73
+ super(new Writable({ /** 啥也不干 */ write: () => { } }), new Writable({ /** 啥也不干 */ write: () => { } }))
65
74
 
66
75
  this.base_console = options.base_console || consoleReflect()
67
76
  delete options.base_console
@@ -77,8 +86,14 @@ export class VirtualConsole extends Console {
77
86
  for (const method of ['log', 'info', 'warn', 'debug', 'error']) {
78
87
  if (!this[method]) continue
79
88
  const originalMethod = this[method]
89
+ /**
90
+ * 将控制台方法重写为捕获输出并根据配置决定是否传递给底层控制台。
91
+ * @param {...any} args - 控制台方法的参数。
92
+ * @returns {void}
93
+ */
80
94
  this[method] = (...args) => {
81
95
  if (method == 'error' && this.options.error_handler && args.length === 1 && args[0] instanceof Error) return this.options.error_handler(args[0])
96
+ if (this.options.recordOutput) this.outputsHtml += argsToHtml(args) + '<br/>\n'
82
97
  if (!this.options.realConsoleOutput || this.options.recordOutput) return originalMethod.apply(this, args)
83
98
  this.#loggedFreshLineId = null
84
99
  return this.#base_console[method](...args)
@@ -86,15 +101,35 @@ export class VirtualConsole extends Console {
86
101
  }
87
102
  }
88
103
 
104
+ /**
105
+ * 获取用于 realConsoleOutput 的底层控制台实例。
106
+ * @returns {Console} 底层控制台实例。
107
+ */
89
108
  get base_console() {
90
109
  return this.#base_console
91
110
  }
92
111
 
112
+ /**
113
+ * 设置用于 realConsoleOutput 的底层控制台实例。
114
+ * @param {Console} value - 底层控制台实例。
115
+ * @returns {void}
116
+ */
93
117
  set base_console(value) {
94
118
  this.#base_console = value
95
119
 
120
+ /**
121
+ * 创建一个虚拟的控制台流,用于捕获输出。
122
+ * @param {NodeJS.WritableStream} targetStream - 目标流。
123
+ * @returns {NodeJS.WritableStream} 虚拟流。
124
+ */
96
125
  const createVirtualStream = (targetStream) => {
97
126
  const virtualStream = new Writable({
127
+ /**
128
+ * 写入数据到虚拟流。
129
+ * @param {Buffer | string} chunk - 要写入的数据块。
130
+ * @param {string} encoding - 编码格式。
131
+ * @param {() => void} callback - 写入完成的回调函数。
132
+ */
98
133
  write: (chunk, encoding, callback) => {
99
134
  this.#loggedFreshLineId = null
100
135
 
@@ -110,14 +145,42 @@ export class VirtualConsole extends Console {
110
145
  if (targetStream.isTTY) {
111
146
  Object.defineProperties(virtualStream, {
112
147
  isTTY: { value: true, configurable: true, writable: false, enumerable: true },
113
- columns: { get: () => targetStream.columns, configurable: true, enumerable: true },
114
- rows: { get: () => targetStream.rows, configurable: true, enumerable: true },
115
- getColorDepth: { get: () => targetStream.getColorDepth.bind(targetStream), configurable: true, enumerable: true },
116
- hasColors: { get: () => targetStream.hasColors.bind(targetStream), configurable: true, enumerable: true },
148
+ columns: {
149
+ /**
150
+ * 获取目标流的列数
151
+ * @returns {number} 列数
152
+ */
153
+ get: () => targetStream.columns, configurable: true, enumerable: true
154
+ },
155
+ rows: {
156
+ /**
157
+ * 获取目标流的行数
158
+ * @returns {number} 行数
159
+ */
160
+ get: () => targetStream.rows, configurable: true, enumerable: true
161
+ },
162
+ getColorDepth: {
163
+ /**
164
+ * 获取目标流的颜色深度
165
+ * @returns {number} 颜色深度
166
+ */
167
+ get: () => targetStream.getColorDepth.bind(targetStream), configurable: true, enumerable: true
168
+ },
169
+ hasColors: {
170
+ /**
171
+ * 判断目标流是否支持颜色
172
+ * @returns {boolean} 是否支持颜色
173
+ */
174
+ get: () => targetStream.hasColors.bind(targetStream), configurable: true, enumerable: true
175
+ },
117
176
  })
118
177
 
119
178
  const virtualStreamRef = new WeakRef(virtualStream)
120
179
 
180
+ /**
181
+ * 监听目标流的 resize 事件,并在虚拟流上触发相应的事件。
182
+ * @returns {void}
183
+ */
121
184
  const resizeListener = () => {
122
185
  virtualStreamRef.deref()?.emit('resize')
123
186
  }
@@ -151,35 +214,58 @@ export class VirtualConsole extends Console {
151
214
  this.#loggedFreshLineId = id
152
215
  }
153
216
 
217
+ /**
218
+ * 清空捕获的输出,并选择性地清空真实控制台。
219
+ * @returns {void}
220
+ */
154
221
  clear() {
155
222
  this.#loggedFreshLineId = null
156
223
  this.outputs = ''
224
+ this.outputsHtml = ''
157
225
  if (this.options.realConsoleOutput)
158
226
  this.#base_console.clear()
159
227
  }
160
228
  }
161
229
 
162
230
  const originalConsole = globalThis.console
231
+ /**
232
+ * 默认的虚拟控制台实例。
233
+ */
163
234
  export const defaultConsole = new VirtualConsole({ base_console: originalConsole, recordOutput: false, realConsoleOutput: true })
235
+ /**
236
+ * 全局控制台的附加属性。
237
+ */
164
238
  export const globalConsoleAdditionalProperties = {}
165
239
  /** @type {() => VirtualConsole} */
166
240
  let consoleReflect = () => consoleAsyncStorage.getStore() ?? defaultConsole
167
241
  /** @type {(value: VirtualConsole) => void} */
168
242
  let consoleReflectSet = (v) => consoleAsyncStorage.enterWith(v)
169
- /** @type {(value: VirtualConsole, fn: () => T) => Promise<T>} */
243
+ /**
244
+ * @template T - fn 函数的返回类型
245
+ * @type {(value: VirtualConsole, fn: () => T) => Promise<T>}
246
+ */
170
247
  let consoleReflectRun = (v, fn) => consoleAsyncStorage.run(v, fn)
171
248
  /**
172
249
  * 设置全局控制台反射逻辑
173
- * @template T
174
- * @param {(console: Console) => Console} Reflect
175
- * @param {(value: Console) => void} ReflectSet
176
- * @param {(value: Console, fn: () => T) => Promise<T>} ReflectRun
250
+ * @template T - fn 函数的返回类型
251
+ * @param {(console: Console) => Console} Reflect 从默认控制台映射到新的控制台对象的函数。
252
+ * @param {(value: Console) => void} ReflectSet 设置当前控制台对象的函数。
253
+ * @param {(value: Console, fn: () => T) => Promise<T>} ReflectRun 在新的异步上下文中执行函数的函数。
254
+ * @returns {void}
177
255
  */
178
256
  export function setGlobalConsoleReflect(Reflect, ReflectSet, ReflectRun) {
257
+ /**
258
+ * 从默认控制台获取当前控制台对象。
259
+ * @returns {Console} 当前控制台对象。
260
+ */
179
261
  consoleReflect = () => Reflect(defaultConsole)
180
262
  consoleReflectSet = ReflectSet
181
263
  consoleReflectRun = ReflectRun
182
264
  }
265
+ /**
266
+ * 获取全局控制台反射逻辑
267
+ * @returns {object} 包含 Reflect、ReflectSet 和 ReflectRun 函数的对象。
268
+ */
183
269
  export function getGlobalConsoleReflect() {
184
270
  return {
185
271
  Reflect: consoleReflect,
@@ -187,7 +273,17 @@ export function getGlobalConsoleReflect() {
187
273
  ReflectRun: consoleReflectRun
188
274
  }
189
275
  }
276
+ /**
277
+ * 全局控制台实例。
278
+ */
190
279
  export const console = globalThis.console = new FullProxy(() => Object.assign({}, globalConsoleAdditionalProperties, consoleReflect()), {
280
+ /**
281
+ * 设置属性时的处理逻辑。
282
+ * @param {object} target - 目标对象。
283
+ * @param {string | symbol} property - 要设置的属性名。
284
+ * @param {any} value - 要设置的属性值。
285
+ * @returns {any} 属性值。
286
+ */
191
287
  set: (target, property, value) => {
192
288
  target = consoleReflect()
193
289
  if (property in target) return Reflect.set(target, property, value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/virtual-console",
3
- "version": "0.0.8",
3
+ "version": "0.1.1",
4
4
  "description": "A virtual console for capturing and manipulating terminal output.",
5
5
  "main": "main.mjs",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "dependencies": {
20
20
  "ansi-escapes": "latest",
21
21
  "full-proxy": "latest",
22
- "supports-ansi": "latest"
22
+ "supports-ansi": "latest",
23
+ "ansi_up": "latest"
23
24
  }
24
25
  }
package/util.mjs ADDED
@@ -0,0 +1,78 @@
1
+ import { AnsiUp } from 'ansi_up'
2
+
3
+ const ansi_up = new AnsiUp()
4
+
5
+ /**
6
+ * 将 console 参数格式化为 HTML 字符串。
7
+ * @param {any[]} args - console 方法接收的参数数组。
8
+ * @returns {string} 格式化后的 HTML 字符串。
9
+ */
10
+ export function argsToHtml(args) {
11
+ if (args.length === 0) return ''
12
+ const format = args[0]
13
+ if (format?.constructor !== String)
14
+ return args.map(arg => {
15
+ if (arg instanceof Error && arg.stack) return ansi_up.ansi_to_html(arg.stack)
16
+ if ((arg === null || arg instanceof Object) && !(arg instanceof Function))
17
+ try { return ansi_up.ansi_to_html(JSON.stringify(arg, null, '\t')) }
18
+ catch { return String(arg) }
19
+
20
+ return ansi_up.ansi_to_html(String(arg))
21
+ }).join(' ')
22
+
23
+
24
+ let html = ansi_up.ansi_to_html(format)
25
+ let argIndex = 1
26
+ let hasStyle = false
27
+
28
+ const regex = /%[sdifoOc%]/g
29
+ html = html.replace(regex, (match) => {
30
+ if (match === '%%') return '%'
31
+ if (argIndex >= args.length) return match
32
+
33
+ const arg = args[argIndex++]
34
+ switch (match) {
35
+ case '%c': {
36
+ hasStyle = true
37
+ const style = String(arg)
38
+ return `</span><span style="${style}">`
39
+ }
40
+ case '%s':
41
+ return ansi_up.ansi_to_html(String(arg))
42
+ case '%d':
43
+ case '%i':
44
+ return String(parseInt(arg))
45
+ case '%f':
46
+ return String(parseFloat(arg))
47
+ case '%o':
48
+ case '%O':
49
+ try { return ansi_up.ansi_to_html(JSON.stringify(arg)) }
50
+ catch { return String(arg) }
51
+ }
52
+ return match
53
+ })
54
+
55
+ if (hasStyle) html = `<span>${html}</span>`
56
+
57
+ const replaceTable = {
58
+ '<span style="">': '<span>',
59
+ '<span></span>': '',
60
+ }
61
+
62
+ Object.entries(replaceTable).forEach(([key, value]) => {
63
+ html = html.replaceAll(key, value)
64
+ })
65
+
66
+ while (argIndex < args.length) {
67
+ const arg = args[argIndex++]
68
+ html += ' '
69
+ if (arg instanceof Error && arg.stack) html += ansi_up.ansi_to_html(arg.stack)
70
+ else if ((arg === null || arg instanceof Object) && !(arg instanceof Function))
71
+ try { html += ansi_up.ansi_to_html(JSON.stringify(arg, null, '\t')) }
72
+ catch { html += String(arg) }
73
+
74
+ else html += ansi_up.ansi_to_html(String(arg))
75
+ }
76
+
77
+ return html
78
+ }