@vobs/logger 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vobs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @vobs/logger
2
+
3
+ Structured, level-filtered logging with pluggable transports, context scoping, and default redaction.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @vobs/logger
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createLogger, createMemoryTransport } from '@vobs/logger'
15
+
16
+ const memory = createMemoryTransport(100)
17
+ const logger = createLogger({
18
+ level: 'info',
19
+ context: { app: 'console' },
20
+ transports: [memory]
21
+ })
22
+
23
+ const request = logger.child({ requestId: 'request-1' })
24
+ request.warn('request failed', { status: 503 })
25
+ await logger.flush()
26
+ ```
27
+
28
+ ## API
29
+
30
+ | Signature | Description |
31
+ | --- | --- |
32
+ | `createLogger(options?: LoggerOptions): Logger` | Creates a logger; defaults to level `'info'` and a console transport. |
33
+ | `logger.level: LogLevel` | Minimum level: `'debug'`, `'info'`, `'warn'`, or `'error'`. |
34
+ | `logger.log(level, message, context?)` / `debug` / `info` / `warn` / `error` | Emit entries; below-level calls are dropped. |
35
+ | `logger.child(context: LogContext): Logger` | Returns a logger whose entries merge the parent and child context. |
36
+ | `logger.flush(): Promise<void>` | Waits for pending async writes, then calls `flush` on each transport. |
37
+ | `logger.dispose(): void` | Stops the logger; the root logger also disposes owned transports. |
38
+ | `createConsoleTransport(target?: ConsoleLike): LogTransport` | Writes `[level] message` plus context to a console-like target. |
39
+ | `createMemoryTransport(limit?): MemoryLogTransport` | Keeps entries in a ring buffer with `entries` and `clear()`. |
40
+ | `loggerPlugin(options?): VobsPlugin` | Provides the logger through `LOGGER_KEY`. |
41
+ | `useLogger(): Logger` | Injects the logger inside components. |
42
+
43
+ Context objects are sanitized before writing: sensitive keys (`password`, `token`, `authorization`, `cookie`, and more, extendable via `redactKeys`) become `[REDACTED]`, circular references become `[Circular]`, depth is capped by `maxDepth` (default 8), and `Error`/`Date` values are serialized. Transport write and flush failures are reported through `onTransportError` and never make logging throw.
44
+
45
+ ## Types
46
+
47
+ `Logger`, `LogLevel`, `LogEntry`, `LogContext`, `LogObject`, `LogValue`, `LogTransport`, `ConsoleLike`, `MemoryLogTransport`, `LoggerOptions`, `LoggerPluginOptions`, `LoggerErrorCode`
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "license": "MIT",
3
+ "files": [
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
7
+ ],
8
+ "name": "@vobs/logger",
9
+ "version": "1.0.0",
10
+ "description": "Structured, transport-based logging for Vobs applications.",
11
+ "type": "module",
12
+ "main": "src/index.ts",
13
+ "types": "src/index.ts",
14
+ "sideEffects": false,
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "dependencies": {
19
+ "@vobs/reactivity": "1.0.0",
20
+ "@vobs/vobs": "1.0.0"
21
+ },
22
+ "scripts": {
23
+ "test": "vitest --environment jsdom"
24
+ }
25
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createText, createVobs } from '@vobs/vobs'
3
+ import {
4
+ LOGGER_KEY,
5
+ LoggerError,
6
+ createConsoleTransport,
7
+ createLogger,
8
+ createMemoryTransport,
9
+ loggerPlugin,
10
+ useLogger
11
+ } from './index'
12
+
13
+ describe('@vobs/logger', () => {
14
+ it('按级别过滤,并合并子日志的结构化上下文', () => {
15
+ const memory = createMemoryTransport()
16
+ const logger = createLogger({
17
+ level: 'info',
18
+ context: { app: 'console', release: 3 },
19
+ transports: [memory],
20
+ clock: () => new Date('2026-09-03T00:00:00.000Z')
21
+ })
22
+
23
+ logger.debug('ignored')
24
+ logger.child({ requestId: 'request-1' }).warn('request failed', { status: 503 })
25
+
26
+ expect(memory.entries).toEqual([{
27
+ timestamp: '2026-09-03T00:00:00.000Z',
28
+ level: 'warn',
29
+ message: 'request failed',
30
+ context: { app: 'console', release: 3, requestId: 'request-1', status: 503 }
31
+ }])
32
+ expect(Object.isFrozen(memory.entries[0])).toBe(true)
33
+ expect(Object.isFrozen(memory.entries[0].context)).toBe(true)
34
+ logger.dispose()
35
+ })
36
+
37
+ it('默认脱敏敏感键,安全处理错误、深层对象和循环值', () => {
38
+ const memory = createMemoryTransport()
39
+ const circular: { self?: unknown } = {}
40
+ circular.self = circular
41
+ const logger = createLogger({ transports: [memory], maxDepth: 2 })
42
+
43
+ logger.error('login failed', {
44
+ token: 'private',
45
+ nested: { authorization: 'secret', value: true },
46
+ error: new Error('denied'),
47
+ circular,
48
+ deep: { one: { two: { three: true } } }
49
+ })
50
+
51
+ expect(memory.entries[0].context).toMatchObject({
52
+ token: '[REDACTED]',
53
+ nested: { authorization: '[REDACTED]', value: true },
54
+ error: { name: 'Error', message: 'denied' },
55
+ circular: { self: '[Circular]' },
56
+ deep: { one: { two: '[MaxDepth]' } }
57
+ })
58
+ logger.dispose()
59
+ })
60
+
61
+ it('flush 等待异步 transport,并隔离 transport 写入和 flush 错误', async () => {
62
+ let resolveWrite: (() => void) | undefined
63
+ const onTransportError = vi.fn()
64
+ const asyncTransport = {
65
+ write: vi.fn(() => new Promise<void>(resolve => { resolveWrite = resolve })),
66
+ flush: vi.fn()
67
+ }
68
+ const brokenTransport = {
69
+ write: () => { throw new Error('write failed') },
70
+ flush: () => { throw new Error('flush failed') }
71
+ }
72
+ const logger = createLogger({ transports: [asyncTransport, brokenTransport], onTransportError })
73
+ logger.info('queued')
74
+ const flushed = logger.flush()
75
+ expect(asyncTransport.flush).not.toHaveBeenCalled()
76
+ resolveWrite?.()
77
+ await flushed
78
+
79
+ expect(asyncTransport.flush).toHaveBeenCalledTimes(1)
80
+ expect(onTransportError).toHaveBeenCalledWith(expect.any(Error), brokenTransport, expect.objectContaining({ message: 'queued' }))
81
+ expect(onTransportError).toHaveBeenCalledWith(expect.any(Error), brokenTransport, undefined)
82
+ logger.dispose()
83
+ })
84
+
85
+ it('console transport 使用对应级别输出,内存 transport 可以限制和清空', () => {
86
+ const output = vi.fn()
87
+ const consoleTransport = createConsoleTransport({ warn: output })
88
+ const memory = createMemoryTransport(2)
89
+ const logger = createLogger({ level: 'debug', transports: [consoleTransport, memory] })
90
+ logger.warn('notice', { count: 1 })
91
+ logger.info('first')
92
+ logger.info('second')
93
+
94
+ expect(output).toHaveBeenCalledWith('[warn] notice', { count: 1 })
95
+ expect(memory.entries.map(entry => entry.message)).toEqual(['first', 'second'])
96
+ memory.clear()
97
+ expect(memory.entries).toEqual([])
98
+ logger.dispose()
99
+ })
100
+
101
+ it('子 Logger 销毁只停止自己的写入,不关闭父 Logger 的共享 transport', () => {
102
+ const memory = createMemoryTransport()
103
+ const logger = createLogger({ transports: [memory] })
104
+ const child = logger.child({ requestId: 'request-1' })
105
+
106
+ child.dispose()
107
+ child.info('ignored')
108
+ logger.info('parent remains active')
109
+
110
+ expect(memory.entries.map(entry => entry.message)).toEqual(['parent remains active'])
111
+ logger.dispose()
112
+ })
113
+
114
+ it('loggerPlugin 注入上下文并在应用销毁后停止写入自有 logger', () => {
115
+ const memory = createMemoryTransport()
116
+ let injected: ReturnType<typeof createLogger> | undefined
117
+ const app = createVobs({
118
+ render: () => createText('logger'),
119
+ plugins: [
120
+ loggerPlugin({ transports: [memory] }),
121
+ { name: 'consumer', install(context) { injected = context.inject(LOGGER_KEY) } }
122
+ ]
123
+ })
124
+
125
+ injected?.info('before destroy')
126
+ app.destroy()
127
+ injected?.info('after destroy')
128
+ expect(memory.entries.map(entry => entry.message)).toEqual(['before destroy'])
129
+ })
130
+
131
+ it('未安装插件时 useLogger 给出明确错误', () => {
132
+ const app = createVobs({ render: () => {
133
+ useLogger()
134
+ return createText('')
135
+ } })
136
+ expect(() => app.mount(document.createElement('div'))).toThrowError(
137
+ expect.objectContaining({ code: 'LOGGER_CONTEXT_MISSING' })
138
+ )
139
+ expect(LoggerError).toBeDefined()
140
+ })
141
+ })
package/src/index.ts ADDED
@@ -0,0 +1,324 @@
1
+ import { getCurrentOwner, onDispose } from '@vobs/reactivity'
2
+ import { createInjectionKey, inject, type InjectionKey, type VobsPlugin } from '@vobs/vobs'
3
+
4
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
5
+
6
+ export type LogValue = string | number | boolean | null | Readonly<LogObject> | readonly LogValue[]
7
+
8
+ export interface LogObject {
9
+ readonly [key: string]: LogValue
10
+ }
11
+
12
+ export type LogContext = Readonly<Record<string, unknown>>
13
+
14
+ export interface LogEntry {
15
+ readonly timestamp: string
16
+ readonly level: LogLevel
17
+ readonly message: string
18
+ readonly context: Readonly<LogObject>
19
+ }
20
+
21
+ export interface LogTransport {
22
+ write(entry: LogEntry): void | PromiseLike<void>
23
+ flush?(): void | PromiseLike<void>
24
+ dispose?(): void
25
+ }
26
+
27
+ export interface Logger {
28
+ readonly level: LogLevel
29
+ log(level: LogLevel, message: string, context?: LogContext): void
30
+ debug(message: string, context?: LogContext): void
31
+ info(message: string, context?: LogContext): void
32
+ warn(message: string, context?: LogContext): void
33
+ error(message: string, context?: LogContext): void
34
+ child(context: LogContext): Logger
35
+ flush(): Promise<void>
36
+ dispose(): void
37
+ }
38
+
39
+ export interface LoggerOptions {
40
+ readonly level?: LogLevel
41
+ readonly context?: LogContext
42
+ readonly transports?: readonly LogTransport[]
43
+ readonly redactKeys?: readonly string[]
44
+ readonly maxDepth?: number
45
+ readonly clock?: () => Date
46
+ readonly onTransportError?: (error: unknown, transport: LogTransport, entry?: LogEntry) => void
47
+ }
48
+
49
+ export interface LoggerPluginOptions extends LoggerOptions {
50
+ readonly logger?: Logger
51
+ }
52
+
53
+ export interface ConsoleLike {
54
+ debug?: (...data: unknown[]) => void
55
+ info?: (...data: unknown[]) => void
56
+ warn?: (...data: unknown[]) => void
57
+ error?: (...data: unknown[]) => void
58
+ log?: (...data: unknown[]) => void
59
+ }
60
+
61
+ export interface MemoryLogTransport extends LogTransport {
62
+ readonly entries: readonly LogEntry[]
63
+ clear(): void
64
+ }
65
+
66
+ export type LoggerErrorCode = 'LOGGER_CONTEXT_MISSING' | 'INVALID_LEVEL' | 'INVALID_TRANSPORT' | 'INVALID_MAX_DEPTH'
67
+
68
+ export class LoggerError extends Error {
69
+ readonly code: LoggerErrorCode
70
+
71
+ constructor(code: LoggerErrorCode, message: string) {
72
+ super(message)
73
+ this.name = 'LoggerError'
74
+ this.code = code
75
+ }
76
+ }
77
+
78
+ export const LOGGER_KEY: InjectionKey<Logger> = createInjectionKey<Logger>('vobs.logger')
79
+
80
+ const levels: Readonly<Record<LogLevel, number>> = {
81
+ debug: 10,
82
+ info: 20,
83
+ warn: 30,
84
+ error: 40
85
+ }
86
+
87
+ const defaultRedactKeys = ['password', 'passwd', 'secret', 'token', 'authorization', 'cookie']
88
+
89
+ interface LoggerState {
90
+ readonly level: LogLevel
91
+ readonly transports: readonly LogTransport[]
92
+ readonly redactKeys: ReadonlySet<string>
93
+ readonly maxDepth: number
94
+ readonly clock: () => Date
95
+ readonly onTransportError: LoggerOptions['onTransportError']
96
+ readonly pending: Set<Promise<void>>
97
+ disposed: boolean
98
+ }
99
+
100
+ export function createLogger(options: LoggerOptions = {}): Logger {
101
+ const level = options.level ?? 'info'
102
+ if (!isLogLevel(level)) throw new LoggerError('INVALID_LEVEL', `Vobs Logger: 不支持日志级别 ${String(level)}`)
103
+
104
+ const maxDepth = options.maxDepth ?? 8
105
+ if (!Number.isInteger(maxDepth) || maxDepth < 0) {
106
+ throw new LoggerError('INVALID_MAX_DEPTH', 'Vobs Logger: maxDepth 必须是大于等于 0 的整数')
107
+ }
108
+
109
+ const transports = options.transports ?? [createConsoleTransport()]
110
+ if (transports.some(transport => !transport || typeof transport.write !== 'function')) {
111
+ throw new LoggerError('INVALID_TRANSPORT', 'Vobs Logger: transport 必须提供 write(entry)')
112
+ }
113
+
114
+ const state: LoggerState = {
115
+ level,
116
+ transports,
117
+ redactKeys: new Set((options.redactKeys ?? defaultRedactKeys).map(key => key.toLowerCase())),
118
+ maxDepth,
119
+ clock: options.clock ?? (() => new Date()),
120
+ onTransportError: options.onTransportError,
121
+ pending: new Set(),
122
+ disposed: false
123
+ }
124
+ const logger = createLoggerScope(state, options.context ?? {}, true)
125
+ if (getCurrentOwner()) onDispose(logger.dispose)
126
+ return logger
127
+ }
128
+
129
+ export function createConsoleTransport(target: ConsoleLike | undefined = globalThis.console): LogTransport {
130
+ return {
131
+ write(entry): void {
132
+ const output = target?.[entry.level] ?? target?.log
133
+ output?.call(target, `[${entry.level}] ${entry.message}`, entry.context)
134
+ }
135
+ }
136
+ }
137
+
138
+ export function createMemoryTransport(limit = Infinity): MemoryLogTransport {
139
+ if ((!Number.isInteger(limit) && limit !== Infinity) || limit <= 0) {
140
+ throw new RangeError('Vobs Logger: memory transport 的 limit 必须是正整数或 Infinity')
141
+ }
142
+
143
+ const entries: LogEntry[] = []
144
+ return {
145
+ get entries(): readonly LogEntry[] {
146
+ return entries
147
+ },
148
+ write(entry): void {
149
+ entries.push(entry)
150
+ if (entries.length > limit) entries.splice(0, entries.length - limit)
151
+ },
152
+ clear(): void {
153
+ entries.length = 0
154
+ }
155
+ }
156
+ }
157
+
158
+ export function loggerPlugin(options: LoggerPluginOptions = {}): VobsPlugin {
159
+ return {
160
+ name: '@vobs/logger',
161
+ version: '0.1.0',
162
+ install(context) {
163
+ const ownedLogger = options.logger ? undefined : createLogger(options)
164
+ context.provide(LOGGER_KEY, options.logger ?? ownedLogger!)
165
+ return () => ownedLogger?.dispose()
166
+ }
167
+ }
168
+ }
169
+
170
+ export function useLogger(): Logger {
171
+ const logger = inject(LOGGER_KEY)
172
+ if (!logger) throw new LoggerError('LOGGER_CONTEXT_MISSING', 'Vobs Logger: 找不到上下文,请安装 loggerPlugin')
173
+ return logger
174
+ }
175
+
176
+ function createLoggerScope(state: LoggerState, baseContext: LogContext, ownsTransports = false): Logger {
177
+ const context = { ...baseContext }
178
+ let disposed = false
179
+ return {
180
+ level: state.level,
181
+
182
+ log(level, message, details = {}): void {
183
+ if (state.disposed || disposed || !isLogLevel(level) || levels[level] < levels[state.level]) return
184
+ const entry = freezeEntry({
185
+ timestamp: state.clock().toISOString(),
186
+ level,
187
+ message,
188
+ context: sanitizeContext({ ...context, ...details }, state.redactKeys, state.maxDepth)
189
+ })
190
+ for (const transport of state.transports) writeToTransport(state, transport, entry)
191
+ },
192
+
193
+ debug(message, details): void {
194
+ this.log('debug', message, details)
195
+ },
196
+
197
+ info(message, details): void {
198
+ this.log('info', message, details)
199
+ },
200
+
201
+ warn(message, details): void {
202
+ this.log('warn', message, details)
203
+ },
204
+
205
+ error(message, details): void {
206
+ this.log('error', message, details)
207
+ },
208
+
209
+ child(details): Logger {
210
+ return createLoggerScope(state, { ...context, ...details })
211
+ },
212
+
213
+ async flush(): Promise<void> {
214
+ while (state.pending.size > 0) await Promise.all([...state.pending])
215
+ for (const transport of state.transports) {
216
+ try {
217
+ await transport.flush?.()
218
+ } catch (error) {
219
+ reportTransportError(state, error, transport)
220
+ }
221
+ }
222
+ },
223
+
224
+ dispose(): void {
225
+ if (disposed) return
226
+ disposed = true
227
+ if (!ownsTransports || state.disposed) return
228
+ state.disposed = true
229
+ for (const transport of state.transports) {
230
+ try {
231
+ transport.dispose?.()
232
+ } catch (error) {
233
+ reportTransportError(state, error, transport)
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ function writeToTransport(state: LoggerState, transport: LogTransport, entry: LogEntry): void {
241
+ try {
242
+ const result = transport.write(entry)
243
+ if (!isPromiseLike(result)) return
244
+ const pending = Promise.resolve(result).catch(error => {
245
+ reportTransportError(state, error, transport, entry)
246
+ })
247
+ state.pending.add(pending)
248
+ void pending.then(() => state.pending.delete(pending))
249
+ } catch (error) {
250
+ reportTransportError(state, error, transport, entry)
251
+ }
252
+ }
253
+
254
+ function reportTransportError(
255
+ state: LoggerState,
256
+ error: unknown,
257
+ transport: LogTransport,
258
+ entry?: LogEntry
259
+ ): void {
260
+ try {
261
+ state.onTransportError?.(error, transport, entry)
262
+ } catch {
263
+ // Transport error reporting must never make application logging throw.
264
+ }
265
+ }
266
+
267
+ function sanitizeContext(input: LogContext, redactKeys: ReadonlySet<string>, maxDepth: number): LogObject {
268
+ const seen = new WeakSet<object>()
269
+ return Object.freeze(sanitizeObject(input, redactKeys, maxDepth, seen))
270
+ }
271
+
272
+ function sanitizeObject(
273
+ input: LogContext,
274
+ redactKeys: ReadonlySet<string>,
275
+ depth: number,
276
+ seen: WeakSet<object>
277
+ ): LogObject {
278
+ const output: Record<string, LogValue> = {}
279
+ for (const [key, value] of Object.entries(input)) {
280
+ output[key] = redactKeys.has(key.toLowerCase())
281
+ ? '[REDACTED]'
282
+ : sanitizeValue(value, redactKeys, depth, seen)
283
+ }
284
+ return Object.freeze(output)
285
+ }
286
+
287
+ function sanitizeValue(
288
+ value: unknown,
289
+ redactKeys: ReadonlySet<string>,
290
+ depth: number,
291
+ seen: WeakSet<object>
292
+ ): LogValue {
293
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
294
+ if (typeof value === 'number') return Number.isFinite(value) ? value : String(value)
295
+ if (typeof value === 'bigint' || typeof value === 'symbol' || typeof value === 'undefined') return String(value)
296
+ if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
297
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString()
298
+ if (value instanceof Error) {
299
+ return Object.freeze({
300
+ name: value.name,
301
+ message: value.message,
302
+ ...(value.stack ? { stack: value.stack } : {})
303
+ })
304
+ }
305
+ if (depth === 0) return '[MaxDepth]'
306
+ if (seen.has(value as object)) return '[Circular]'
307
+ seen.add(value as object)
308
+ if (Array.isArray(value)) {
309
+ return Object.freeze(value.map(item => sanitizeValue(item, redactKeys, depth - 1, seen)))
310
+ }
311
+ return sanitizeObject(value as LogContext, redactKeys, depth - 1, seen)
312
+ }
313
+
314
+ function freezeEntry(entry: LogEntry): LogEntry {
315
+ return Object.freeze(entry)
316
+ }
317
+
318
+ function isPromiseLike(value: unknown): value is PromiseLike<void> {
319
+ return Boolean(value) && typeof (value as PromiseLike<void>).then === 'function'
320
+ }
321
+
322
+ function isLogLevel(value: unknown): value is LogLevel {
323
+ return typeof value === 'string' && value in levels
324
+ }