@steve02081504/virtual-console 0.0.0
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 +180 -0
- package/main.mjs +170 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Virtual Console
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@steve02081504/virtual-console)
|
|
4
|
+
[](https://github.com/steve02081504/virtual-console/blob/main/LICENSE)
|
|
5
|
+
[](https://github.com/steve02081504/virtual-console/issues)
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
`VirtualConsole` is perfect for:
|
|
10
|
+
|
|
11
|
+
- **Testing:** Assert console output from your modules without polluting the test runner's output.
|
|
12
|
+
- **Logging Frameworks:** Create custom logging solutions that can buffer, format, or redirect logs.
|
|
13
|
+
- **CLI Tools:** Build interactive command-line interfaces with updatable status lines or progress bars.
|
|
14
|
+
- **Debugging:** Isolate and inspect output from specific parts of your application, even in highly concurrent scenarios.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Core Concept: The Global Console Proxy
|
|
19
|
+
|
|
20
|
+
**This library is designed for zero-refactoring integration.** Upon import, it replaces `globalThis.console` with a smart proxy. Here’s how it works:
|
|
21
|
+
|
|
22
|
+
1. **Default Behavior (No-op):** By default, the proxy simply forwards all console calls (`console.log`, etc.) to the original, real console. It doesn't record or change anything. This makes the library safe to include anywhere without side effects.
|
|
23
|
+
|
|
24
|
+
2. **Activation via `hookAsyncContext`:** To capture or manipulate output, you create a `new VirtualConsole()` instance and activate it for a specific block of code using `vc.hookAsyncContext(yourFunction)`.
|
|
25
|
+
|
|
26
|
+
3. **Context-Aware Routing:** Inside the `hookAsyncContext` block, the proxy detects the active `VirtualConsole` instance and routes all `console` calls to it. This allows your instance to capture output, handle stateful methods like `freshLine`, or apply custom logic, all while being completely isolated from other asynchronous operations.
|
|
27
|
+
|
|
28
|
+
This architecture means you **don't need to pass console instances around**. Just keep using the global `console` as you always have, and wrap the code you want to monitor.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Zero-Configuration Capturing:** Capture output from any module without changing its source code.
|
|
33
|
+
- **Concurrency-Safe Isolation:** Uses `AsyncLocalStorage` to guarantee that output from concurrent operations is captured independently and correctly.
|
|
34
|
+
- **Output Recording:** Captures all `stdout` and `stderr` output to a string property for inspection.
|
|
35
|
+
- **Real Console Passthrough:** Optionally, print to the actual console while also capturing.
|
|
36
|
+
- **TTY Emulation:** Behaves like a real TTY, inheriting properties like `columns`, `rows`, and color support.
|
|
37
|
+
- **Updatable Lines (`freshLine`)**: A stateful method for creating overwritable lines, perfect for progress indicators.
|
|
38
|
+
- **Extensible:** Provide a custom base console, define a dedicated `Error` handler, and more.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @steve02081504/virtual-console
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start: Testing Console Output
|
|
47
|
+
|
|
48
|
+
The most common use case is testing. Wrap your function call in `hookAsyncContext` and then assert the captured output.
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
import { VirtualConsole } from '@steve02081504/virtual-console';
|
|
52
|
+
import { strict as assert } from 'node:assert';
|
|
53
|
+
|
|
54
|
+
// 1. A function that logs to the console
|
|
55
|
+
function greet(name) {
|
|
56
|
+
console.log(`Hello, ${name}!`);
|
|
57
|
+
console.error('An example error.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. In your test:
|
|
61
|
+
async function testGreeting() {
|
|
62
|
+
const vc = new VirtualConsole();
|
|
63
|
+
|
|
64
|
+
// 3. Run the function inside the hook to capture its output.
|
|
65
|
+
// All `console.*` calls inside `greet` are now routed to `vc`.
|
|
66
|
+
await vc.hookAsyncContext(() => greet('World'));
|
|
67
|
+
|
|
68
|
+
// 4. Assert the captured output
|
|
69
|
+
const expectedOutput = 'Hello, World!\nAn example error.\n';
|
|
70
|
+
assert.strictEqual(vc.outputs, expectedOutput);
|
|
71
|
+
console.log('Test passed!');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
testGreeting();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Advanced Usage
|
|
78
|
+
|
|
79
|
+
### Use Case: Concurrent Progress Bars with `freshLine`
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
import { VirtualConsole } from '@steve02081504/virtual-console';
|
|
85
|
+
|
|
86
|
+
const vc = new VirtualConsole({ realConsoleOutput: true });
|
|
87
|
+
|
|
88
|
+
async function updateProgress(taskName, duration) {
|
|
89
|
+
for (let i = 0; i <= 100; i += 20) {
|
|
90
|
+
// `console.freshLine` is a stateful method. It works correctly here
|
|
91
|
+
// because each task has its own isolated VirtualConsole state.
|
|
92
|
+
console.freshLine(taskName, `[${taskName}]: ${i}%`);
|
|
93
|
+
await new Promise(res => setTimeout(res, duration));
|
|
94
|
+
}
|
|
95
|
+
console.log(`[${taskName}]: Done!`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log('Starting concurrent tasks...');
|
|
99
|
+
|
|
100
|
+
// Run two tasks, each in its own isolated hook.
|
|
101
|
+
// Without this isolation, they would conflict and corrupt the output.
|
|
102
|
+
await Promise.all([
|
|
103
|
+
vc.hookAsyncContext(() => updateProgress('Upload-A', 50)),
|
|
104
|
+
vc.hookAsyncContext(() => updateProgress('Process-B', 75)),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
console.log('All tasks finished!');
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Use Case: Activating a Hook for an Entire Async Flow
|
|
111
|
+
|
|
112
|
+
You can call `hookAsyncContext()` without a function to activate an instance for the *rest of the current asynchronous context*. This is useful in frameworks like Express or Koa middleware.
|
|
113
|
+
|
|
114
|
+
```javascript
|
|
115
|
+
const vc = new VirtualConsole({ realConsoleOutput: true });
|
|
116
|
+
|
|
117
|
+
async function middleware(next) {
|
|
118
|
+
console.log('Middleware: Activating virtual console for this request.');
|
|
119
|
+
// Activate `vc` for the rest of this async flow
|
|
120
|
+
vc.hookAsyncContext();
|
|
121
|
+
|
|
122
|
+
// Now, `next()` and any subsequent code in this request's
|
|
123
|
+
// async chain will have its console output handled by `vc`.
|
|
124
|
+
await next();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handler() {
|
|
128
|
+
console.log('Handler: This output is being captured.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Simulate a request
|
|
132
|
+
await middleware(handler);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## API Reference
|
|
136
|
+
|
|
137
|
+
### `new VirtualConsole(options?)`
|
|
138
|
+
|
|
139
|
+
Creates a new `VirtualConsole` instance.
|
|
140
|
+
|
|
141
|
+
- `options` `<object>`
|
|
142
|
+
- `realConsoleOutput` `<boolean>`: If `true`, output is also sent to the base console. **Default:** `false`.
|
|
143
|
+
- `recordOutput` `<boolean>`: If `true`, output is captured in the `outputs` property. **Default:** `true`.
|
|
144
|
+
- `base_console` `<Console>`: The console instance for passthrough. **Default:** The original `global.console`.
|
|
145
|
+
- `error_handler` `<function(Error): void>`: A dedicated handler for `Error` objects passed to `console.error`.
|
|
146
|
+
- `supportsAnsi` `<boolean>`: Manually set ANSI support. **Default:** Inherited from `base_console`.
|
|
147
|
+
|
|
148
|
+
### `virtualConsole.hookAsyncContext(fn?)`
|
|
149
|
+
|
|
150
|
+
Hooks the virtual console into an asynchronous context.
|
|
151
|
+
|
|
152
|
+
- **`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 the result of `fn`.
|
|
153
|
+
- **`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).
|
|
154
|
+
|
|
155
|
+
### `virtualConsole.outputs`
|
|
156
|
+
|
|
157
|
+
- `<string>`
|
|
158
|
+
|
|
159
|
+
A string containing all captured `stdout` and `stderr` output.
|
|
160
|
+
|
|
161
|
+
### `console.freshLine(id, ...args)`
|
|
162
|
+
|
|
163
|
+
*Note: This stateful method is available on the global `console` object but only works as intended inside a `hookAsyncContext`.*
|
|
164
|
+
|
|
165
|
+
Prints a line. If the previously printed line (within the same hook) had the same `id`, it overwrites the previous line.
|
|
166
|
+
|
|
167
|
+
- `id` `<string>`: A unique identifier for the overwritable line.
|
|
168
|
+
- `...args` `<any>`: The content to print, same as `console.log()`.
|
|
169
|
+
|
|
170
|
+
### `virtualConsole.clear()`
|
|
171
|
+
|
|
172
|
+
Clears the captured `outputs` string. If `realConsoleOutput` is enabled, it also attempts to call `clear()` on the base console.
|
|
173
|
+
|
|
174
|
+
### For Library Authors: `setGlobalConsoleReflect(...)`
|
|
175
|
+
|
|
176
|
+
`VirtualConsole` exposes its `AsyncLocalStorage`-based reflection mechanism. If you are building another library that manages async context, you can use `setGlobalConsoleReflect` to integrate `VirtualConsole`'s logic into your own context manager, preventing conflicts. See the source code for details.
|
|
177
|
+
|
|
178
|
+
## Contributing
|
|
179
|
+
|
|
180
|
+
Contributions are welcome! Please open an issue or submit a pull request on [GitHub](https://github.com/steve02081504/VirtualConsole).
|
package/main.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { Console } from 'node:console'
|
|
3
|
+
import { Writable } from 'node:stream'
|
|
4
|
+
import ansiEscapes from 'ansi-escapes'
|
|
5
|
+
import supportsAnsi from 'supports-ansi'
|
|
6
|
+
import { FullProxy } from 'full-proxy'
|
|
7
|
+
|
|
8
|
+
export const consoleAsyncStorage = new AsyncLocalStorage()
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 创建一个虚拟控制台,用于捕获输出,同时可以选择性地将输出传递给真实的控制台。
|
|
12
|
+
*
|
|
13
|
+
* @extends {Console}
|
|
14
|
+
*/
|
|
15
|
+
export class VirtualConsole extends Console {
|
|
16
|
+
/**
|
|
17
|
+
* 在新的Async上下文中执行fn,并将fn上下文的控制台替换为此对象。
|
|
18
|
+
* @template T
|
|
19
|
+
* @overload
|
|
20
|
+
* @param {() => T} fn - 在新的Async上下文中执行的函数。
|
|
21
|
+
* @returns {Promise<T>} 返回 fn 函数的 Promise 结果。
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* 将当前Async上下文中的控制台替换为此对象。
|
|
25
|
+
* @overload
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* 若提供fn,则在新的Async上下文中执行fn,并将fn上下文的控制台替换为此对象。
|
|
30
|
+
* 否则,将当前Async上下文中的控制台替换为此对象。
|
|
31
|
+
* @param {(() => T) | undefined} [fn]
|
|
32
|
+
* @returns {Promise<T> | void}
|
|
33
|
+
*/
|
|
34
|
+
hookAsyncContext(fn) {
|
|
35
|
+
if (fn) return consoleReflectRun(this, fn)
|
|
36
|
+
else consoleReflectSet(this)
|
|
37
|
+
}
|
|
38
|
+
/** @type {string} - 捕获的所有输出 */
|
|
39
|
+
outputs = ''
|
|
40
|
+
|
|
41
|
+
/** @type {object} - 最终合并后的配置项 */
|
|
42
|
+
options
|
|
43
|
+
|
|
44
|
+
/** @type {Console} - 用于 realConsoleOutput 的底层控制台实例 */
|
|
45
|
+
#base_console
|
|
46
|
+
|
|
47
|
+
/** @private @type {string | null} - 用于 freshLine 功能,记录上一次 freshLine 的 ID */
|
|
48
|
+
#loggedFreshLineId = null
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} [options={}] - 配置选项。
|
|
52
|
+
* @param {boolean} [options.realConsoleOutput=false] - 如果为 true,则在捕获输出的同时,也调用底层控制台进行实际输出。
|
|
53
|
+
* @param {boolean} [options.recordOutput=true] - 如果为 true,则捕获输出并保存在 outputs 属性中。
|
|
54
|
+
* @param {function(Error): void} [options.error_handler=null] - 一个专门处理单个 Error 对象的错误处理器。
|
|
55
|
+
* @param {Console} [options.base_console=console] - 用于 realConsoleOutput 的底层控制台实例。
|
|
56
|
+
*/
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
super(new Writable({ write: () => { } }), new Writable({ write: () => { } }))
|
|
59
|
+
|
|
60
|
+
this.base_console = options.base_console || consoleReflect()
|
|
61
|
+
delete options.base_console
|
|
62
|
+
this.options = {
|
|
63
|
+
realConsoleOutput: false,
|
|
64
|
+
recordOutput: true,
|
|
65
|
+
supportsAnsi: this.#base_console.options?.supportsAnsi || supportsAnsi,
|
|
66
|
+
error_handler: null,
|
|
67
|
+
...options,
|
|
68
|
+
}
|
|
69
|
+
this.freshLine = this.freshLine.bind(this)
|
|
70
|
+
this.error = this.error.bind(this)
|
|
71
|
+
this.clear = this.clear.bind(this)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get base_console() {
|
|
75
|
+
return this.#base_console
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
set base_console(value) {
|
|
79
|
+
this.#base_console = value
|
|
80
|
+
|
|
81
|
+
const createVirtualStream = (targetStream) => {
|
|
82
|
+
const virtualStream = new Writable({
|
|
83
|
+
write: (chunk, encoding, callback) => {
|
|
84
|
+
this.#loggedFreshLineId = null
|
|
85
|
+
|
|
86
|
+
if (this.options.recordOutput)
|
|
87
|
+
this.outputs += chunk.toString()
|
|
88
|
+
if (this.options.realConsoleOutput)
|
|
89
|
+
targetStream.write(chunk, encoding)
|
|
90
|
+
callback()
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (targetStream.isTTY) {
|
|
95
|
+
Object.defineProperties(virtualStream, {
|
|
96
|
+
isTTY: { value: true, configurable: true, writable: false, enumerable: true },
|
|
97
|
+
columns: { get: () => targetStream.columns, configurable: true, enumerable: true },
|
|
98
|
+
rows: { get: () => targetStream.rows, configurable: true, enumerable: true },
|
|
99
|
+
getColorDepth: { get: () => targetStream.getColorDepth.bind(targetStream), configurable: true, enumerable: true },
|
|
100
|
+
hasColors: { get: () => targetStream.hasColors.bind(targetStream), configurable: true, enumerable: true },
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
targetStream.on?.('resize', () => {
|
|
104
|
+
virtualStream.emit('resize')
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return virtualStream
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this._stdout = createVirtualStream(this.#base_console._stdout || process.stdout)
|
|
112
|
+
this._stderr = createVirtualStream(this.#base_console._stderr || process.stderr)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 在终端中打印一行,如果前一次调用也是具有相同ID的freshLine,
|
|
117
|
+
* 则会覆盖上一行而不是打印新行。
|
|
118
|
+
* @param {string} id - 用于标识可覆盖行的唯一ID。
|
|
119
|
+
* @param {...any} args - 要打印的内容。
|
|
120
|
+
*/
|
|
121
|
+
freshLine(id, ...args) {
|
|
122
|
+
if (this.options.supportsAnsi && this.#loggedFreshLineId === id)
|
|
123
|
+
this._stdout.write(ansiEscapes.cursorUp(1) + ansiEscapes.eraseLine)
|
|
124
|
+
|
|
125
|
+
this.log(...args)
|
|
126
|
+
this.#loggedFreshLineId = id
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
error(...args) {
|
|
130
|
+
if (this.options.error_handler && args.length === 1 && args[0] instanceof Error)
|
|
131
|
+
return this.options.error_handler(args[0])
|
|
132
|
+
super.error(...args)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clear() {
|
|
136
|
+
this.#loggedFreshLineId = null
|
|
137
|
+
this.outputs = ''
|
|
138
|
+
if (this.options.realConsoleOutput)
|
|
139
|
+
this.#base_console.clear()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const originalConsole = globalThis.console
|
|
144
|
+
const defaultConsole = new VirtualConsole({ base_console: originalConsole, recordOutput: false, realConsoleOutput: true })
|
|
145
|
+
/** @type {() => VirtualConsole} */
|
|
146
|
+
let consoleReflect = () => consoleAsyncStorage.getStore() ?? defaultConsole
|
|
147
|
+
/** @type {(value: VirtualConsole) => void} */
|
|
148
|
+
let consoleReflectSet = (v) => consoleAsyncStorage.enterWith(v)
|
|
149
|
+
/** @type {(value: VirtualConsole, fn: () => T) => Promise<T>} */
|
|
150
|
+
let consoleReflectRun = (v, fn) => consoleAsyncStorage.run(v, fn)
|
|
151
|
+
/**
|
|
152
|
+
* 设置全局控制台反射逻辑
|
|
153
|
+
* @template T
|
|
154
|
+
* @param {(console: Console) => Console} Reflect
|
|
155
|
+
* @param {(value: Console) => void} ReflectSet
|
|
156
|
+
* @param {(value: Console, fn: () => T) => Promise<T>} ReflectRun
|
|
157
|
+
*/
|
|
158
|
+
export function setGlobalConsoleReflect(Reflect, ReflectSet, ReflectRun) {
|
|
159
|
+
consoleReflect = () => Reflect(defaultConsole)
|
|
160
|
+
consoleReflectSet = ReflectSet
|
|
161
|
+
consoleReflectRun = ReflectRun
|
|
162
|
+
}
|
|
163
|
+
export function getGlobalConsoleReflect() {
|
|
164
|
+
return {
|
|
165
|
+
Reflect: consoleReflect,
|
|
166
|
+
ReflectSet: consoleReflectSet,
|
|
167
|
+
ReflectRun: consoleReflectRun
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export const console = globalThis.console = new FullProxy(() => consoleReflect())
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@steve02081504/virtual-console",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A virtual console for capturing and manipulating terminal output.",
|
|
5
|
+
"main": "main.mjs",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"console",
|
|
9
|
+
"virtual",
|
|
10
|
+
"capture",
|
|
11
|
+
"terminal",
|
|
12
|
+
"output"
|
|
13
|
+
],
|
|
14
|
+
"author": "steve02081504",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/steve02081504/virtual-console/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/steve02081504/virtual-console#readme",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ansi-escapes": "latest",
|
|
21
|
+
"full-proxy": "latest",
|
|
22
|
+
"supports-ansi": "latest"
|
|
23
|
+
}
|
|
24
|
+
}
|