diff --git a/docs/rfcs/0001-unified-logging-api-examples.txt b/docs/rfcs/0001-unified-logging-api-examples.txt new file mode 100644 index 00000000..d2b4019b --- /dev/null +++ b/docs/rfcs/0001-unified-logging-api-examples.txt @@ -0,0 +1,273 @@ +# Logger Migration Examples + +This document shows before/after examples of migrating existing `console.*` calls to the unified logger API. + +## Node.js (Server) Examples + +### packages/core/src/node/ws.ts + +**Before:** +```typescript +import c from 'ansis' +import { MARK_INFO } from './constants' + +// ... + +if (isClientAuthDisabled) { + console.warn('[Vite DevTools] Client authentication is disabled. Any browser can connect to the devtools and access to your server and filesystem.') +} + +// ... + +console.log(color`${MARK_INFO} Websocket client connected. [${meta.id}] [${meta.clientAuthId}] (${meta.isTrusted ? 'trusted' : 'untrusted'})`) + +// ... + +console.log(c.red`${MARK_INFO} Websocket client disconnected. [${meta.id}]`) + +// ... + +console.error(c.red`⬢ RPC error on executing "${c.bold(name)}":`) +console.error(error) +``` + +**After:** +```typescript +import { createNodeLogger } from '@vitejs/devtools-kit/utils/logger-node' + +const logger = createNodeLogger({ scope: 'vite-devtools:ws' }) + +// ... + +if (isClientAuthDisabled) { + logger.warn('Client authentication is disabled. Any browser can connect to the devtools and access to your server and filesystem.') +} + +// ... + +logger.info('Websocket client connected', { + id: meta.id, + clientAuthId: meta.clientAuthId, + trusted: meta.isTrusted +}) + +// ... + +logger.info('Websocket client disconnected', { id: meta.id }) + +// ... + +logger.error('RPC error on executing method', { method: name, error }) +``` + +--- + +### packages/core/src/node/context.ts + +**Before:** +```typescript +catch (error) { + console.error(`[Vite DevTools] Error setting up plugin ${plugin.name}:`, error) + throw error +} +``` + +**After:** +```typescript +import { createNodeLogger } from '@vitejs/devtools-kit/utils/logger-node' + +const logger = createNodeLogger({ scope: 'vite-devtools:context' }) + +// ... + +catch (error) { + logger.error(`Error setting up plugin ${plugin.name}`, { + plugin: plugin.name, + error: error as Error + }) + throw error +} +``` + +--- + +### packages/core/src/node/cli-commands.ts + +**Before:** +```typescript +console.log(c.green`${MARK_NODE} Vite DevTools started at`, c.green(`http://${host === '127.0.0.1' ? 'localhost' : host}:${port}`), '\n') + +console.log(c.cyan`${MARK_NODE} Building static Vite DevTools...`) +``` + +**After:** +```typescript +import { createNodeLogger } from '@vitejs/devtools-kit/utils/logger-node' + +const logger = createNodeLogger({ scope: 'vite-devtools:cli' }) + +// ... + +logger.info(`Vite DevTools started at http://${host === '127.0.0.1' ? 'localhost' : host}:${port}`) + +logger.info('Building static Vite DevTools...') +``` + +--- + +## Client (Browser) Examples + +### packages/core/src/client/inject/index.ts + +**Before:** +```typescript +console.log('[VITE DEVTOOLS] Client injected') + +// ... + +console.log('[VITE DEVTOOLS] Skipping in iframe') +``` + +**After:** +```typescript +import { createClientLogger } from '@vitejs/devtools-kit/utils/logger-client' + +const logger = createClientLogger({ scope: 'vite-devtools:inject' }) + +// ... + +logger.info('Client injected') + +// ... + +logger.info('Skipping in iframe') +``` + +--- + +### packages/core/src/client/webcomponents/state/setup-script.ts + +**Before:** +```typescript +.catch((error) => { + // TODO: maybe popup a error toast here? + // TODO: A unified logger API + console.error('[VITE DEVTOOLS] Error executing import action', error) + return Promise.reject(error) +}) +``` + +**After:** +```typescript +import { createClientLogger } from '@vitejs/devtools-kit/utils/logger-client' + +const logger = createClientLogger({ scope: 'vite-devtools:setup-script' }) + +// ... + + .catch((error) => { + logger.error('Error executing import action', { + entryId: id, + error: error as Error + }) + // TODO: integrate with toast notification system + return Promise.reject(error) + }) +``` + +--- + +### packages/vite/src/app/composables/rpc.ts + +**Before:** +```typescript +rpcOptions: { + onGeneralError: (e, name) => { + connectionState.error = e + console.error(`[vite-devtools] RPC error on executing "${name}":`) + }, + onFunctionError: (e, name) => { + connectionState.error = e + console.error(`[vite-devtools] RPC error on executing "${name}":`) + }, +}, +``` + +**After:** +```typescript +import { createClientLogger } from '@vitejs/devtools-kit/utils/logger-client' + +const logger = createClientLogger({ scope: 'vite-devtools:rpc' }) + +// ... + +rpcOptions: { + onGeneralError: (e, name) => { + connectionState.error = e + logger.error(`RPC error on executing "${name}"`, { method: name, error: e }) + }, + onFunctionError: (e, name) => { + connectionState.error = e + logger.error(`RPC error on executing "${name}"`, { method: name, error: e }) + }, +}, +``` + +--- + +## Log Aggregation Integration + +To enable the Logs panel in the DevTools, the context needs to collect logs: + +```typescript +// packages/core/src/node/context.ts +import { createNodeLogger, createLogCollector } from '@vitejs/devtools-kit/utils/logger' + +export async function createDevToolsContext(...) { + // Create log collector for the Logs panel + const logCollector = createLogCollector({ maxEntries: 2000 }) + + // Create logger that feeds into collector + const logger = createNodeLogger({ + scope: 'vite-devtools', + onLog: (entry) => logCollector.add(entry), + }) + + const context: DevToolsNodeContext = { + // ... existing properties + logger, + logs: logCollector, // Expose for RPC/UI + } + + // Add RPC method to fetch logs + context.rpc.register({ + name: 'vite:internal:logs:get', + type: 'action', + setup: () => async (filter) => { + return logCollector.getEntries(filter) + }, + }) + + // Add RPC method to subscribe to live logs (via shared state) + const logsSharedState = await context.rpc.sharedState.get('vite:internal:logs', { + initialValue: [], + }) + + logCollector.subscribe((entries) => { + // Only send last 100 entries for live view + logsSharedState.mutate(() => entries.slice(-100)) + }) +} +``` + +Then enable the Logs panel in `host-docks.ts`: + +```typescript +{ + type: '~builtin', + id: '~logs', + title: 'Logs', + icon: 'ph:notification-duotone', + isHidden: false, // Now enabled! +}, +``` diff --git a/docs/rfcs/0001-unified-logging-api.txt b/docs/rfcs/0001-unified-logging-api.txt new file mode 100644 index 00000000..f4e049df --- /dev/null +++ b/docs/rfcs/0001-unified-logging-api.txt @@ -0,0 +1,557 @@ +# RFC: Unified Logging API for Vite DevTools + +## Summary + +This RFC proposes a unified logging API for the Vite DevTools project to replace scattered `console.*` calls with a consistent, configurable, and feature-rich logging system that works across both Node.js (server) and browser (client) environments. + +## Motivation + +Currently, logging in Vite DevTools is inconsistent: + +1. **Inconsistent prefixes**: Some logs use `[VITE DEVTOOLS]`, others use `[Vite DevTools]`, `[vite-devtools]`, or `⬢` +2. **No log levels**: All logs go to console without filtering capability +3. **No structured logging**: Logs are plain strings with no metadata +4. **No centralized control**: Debug logs use `obug`/`createDebug`, but regular logs use raw `console.*` +5. **No log aggregation**: TODOs in the codebase mention wanting a "Logs" panel (see `host-docks.ts`) +6. **No error toast system**: TODOs mention wanting popup error toasts on the client + +### Current State + +```typescript +// Different prefixes used across the codebase: +console.log('[VITE DEVTOOLS] Client injected') +console.error('[Vite DevTools] Error setting up plugin...') +console.error('[vite-devtools] RPC error on executing...') +console.warn('[Vite DevTools] Client authentication is disabled...') +console.log(c.green`${MARK_NODE} Vite DevTools started at...`) +``` + +## Detailed Design + +### 1. Logger Interface + +```typescript +// packages/kit/src/utils/logger.ts + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent' + +export interface LogEntry { + level: LogLevel + message: string + timestamp: number + scope?: string + meta?: Record + error?: Error +} + +export interface LoggerOptions { + /** Minimum log level to output */ + level?: LogLevel + /** Scope/namespace for the logger (e.g., 'rpc', 'ws', 'client') */ + scope?: string + /** Whether to include timestamps */ + timestamps?: boolean + /** Custom log handler for aggregation */ + onLog?: (entry: LogEntry) => void +} + +export interface Logger { + debug: (message: string, meta?: Record) => void + info: (message: string, meta?: Record) => void + warn: (message: string, meta?: Record) => void + error: (message: string | Error, meta?: Record) => void + + /** Create a child logger with a sub-scope */ + child: (scope: string) => Logger + + /** Update logger options at runtime */ + setLevel: (level: LogLevel) => void +} +``` + +### 2. Node.js Logger Implementation + +```typescript +// packages/kit/src/utils/logger-node.ts + +import c from 'ansis' + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + silent: 4, +} + +const LEVEL_COLORS = { + debug: c.gray, + info: c.blue, + warn: c.yellow, + error: c.red, +} + +const LEVEL_ICONS = { + debug: '🔍', + info: 'ℹ', + warn: '⚠', + error: '✖', +} + +export function createNodeLogger(options: LoggerOptions = {}): Logger { + const { + level = 'info', + scope = 'vite-devtools', + timestamps = false, + onLog, + } = options + + let currentLevel = level + + function shouldLog(msgLevel: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[msgLevel] >= LOG_LEVEL_PRIORITY[currentLevel] + } + + function formatScope(s: string): string { + return c.cyan`[${s}]` + } + + function log(entry: LogEntry): void { + onLog?.(entry) + + if (!shouldLog(entry.level) || entry.level === 'silent') + return + + const color = LEVEL_COLORS[entry.level] + const icon = LEVEL_ICONS[entry.level] + const parts: string[] = [] + + if (timestamps) { + parts.push(c.gray(new Date(entry.timestamp).toISOString())) + } + + parts.push(color(`${icon}`)) + + if (entry.scope) { + parts.push(formatScope(entry.scope)) + } + + parts.push(entry.message) + + const method = entry.level === 'debug' ? 'log' : entry.level + console[method](parts.join(' ')) + + if (entry.error) { + console.error(entry.error) + } + + if (entry.meta && Object.keys(entry.meta).length > 0) { + console.log(c.gray(' Meta:'), entry.meta) + } + } + + function createLogMethod(level: LogLevel) { + return (message: string | Error, meta?: Record) => { + const entry: LogEntry = { + level, + message: message instanceof Error ? message.message : message, + timestamp: Date.now(), + scope, + meta, + error: message instanceof Error ? message : undefined, + } + log(entry) + } + } + + return { + debug: createLogMethod('debug'), + info: createLogMethod('info'), + warn: createLogMethod('warn'), + error: createLogMethod('error'), + + child(childScope: string): Logger { + return createNodeLogger({ + ...options, + level: currentLevel, + scope: scope ? `${scope}:${childScope}` : childScope, + onLog, + }) + }, + + setLevel(newLevel: LogLevel) { + currentLevel = newLevel + }, + } +} + +// Singleton for convenience +export const logger = createNodeLogger() +``` + +### 3. Client Logger Implementation + +```typescript +// packages/kit/src/utils/logger-client.ts + +import type { LogEntry, Logger, LoggerOptions, LogLevel } from './logger' + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + silent: 4, +} + +const LEVEL_STYLES = { + debug: 'color: gray', + info: 'color: #3b82f6', + warn: 'color: #f59e0b', + error: 'color: #ef4444; font-weight: bold', +} + +export function createClientLogger(options: LoggerOptions = {}): Logger { + const { + level = 'info', + scope = 'vite-devtools', + timestamps = false, + onLog, + } = options + + let currentLevel = level + + function shouldLog(msgLevel: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[msgLevel] >= LOG_LEVEL_PRIORITY[currentLevel] + } + + function log(entry: LogEntry): void { + onLog?.(entry) + + if (!shouldLog(entry.level) || entry.level === 'silent') + return + + const style = LEVEL_STYLES[entry.level] + const prefix = entry.scope ? `[${entry.scope}]` : '[vite-devtools]' + const time = timestamps ? `${new Date(entry.timestamp).toISOString()} ` : '' + + const method = entry.level === 'debug' ? 'log' : entry.level + console[method]( + `%c${time}${prefix}%c ${entry.message}`, + style, + 'color: inherit', + ...(entry.meta ? [entry.meta] : []), + ) + + if (entry.error) { + console.error(entry.error) + } + } + + function createLogMethod(level: LogLevel) { + return (message: string | Error, meta?: Record) => { + const entry: LogEntry = { + level, + message: message instanceof Error ? message.message : message, + timestamp: Date.now(), + scope, + meta, + error: message instanceof Error ? message : undefined, + } + log(entry) + } + } + + return { + debug: createLogMethod('debug'), + info: createLogMethod('info'), + warn: createLogMethod('warn'), + error: createLogMethod('error'), + + child(childScope: string): Logger { + return createClientLogger({ + ...options, + level: currentLevel, + scope: scope ? `${scope}:${childScope}` : childScope, + onLog, + }) + }, + + setLevel(newLevel: LogLevel) { + currentLevel = newLevel + }, + } +} + +// Singleton for convenience +export const logger = createClientLogger() +``` + +### 4. Universal Entry Point + +```typescript +// packages/kit/src/utils/logger.ts + +export type * from './logger-types' + +// Re-export the appropriate logger based on environment +// This allows tree-shaking and proper bundling + +export function createLogger(options?: LoggerOptions): Logger { + if (typeof window === 'undefined') { + // Node.js environment + const { createNodeLogger } = await import('./logger-node') + return createNodeLogger(options) + } + else { + // Browser environment + const { createClientLogger } = await import('./logger-client') + return createClientLogger(options) + } +} +``` + +### 5. Log Aggregation for DevTools Panel + +To support the "Logs" panel mentioned in the TODO, we can collect logs: + +```typescript +// packages/kit/src/utils/log-collector.ts + +import type { LogEntry } from './logger-types' + +export interface LogCollector { + entries: LogEntry[] + maxEntries: number + + add: (entry: LogEntry) => void + clear: () => void + getEntries: (filter?: { level?: LogLevel, scope?: string }) => LogEntry[] + + // For reactive updates + subscribe: (callback: (entries: LogEntry[]) => void) => () => void +} + +export function createLogCollector(maxEntries = 1000): LogCollector { + const entries: LogEntry[] = [] + const subscribers = new Set<(entries: LogEntry[]) => void>() + + function notify() { + subscribers.forEach(cb => cb([...entries])) + } + + return { + entries, + maxEntries, + + add(entry: LogEntry) { + entries.push(entry) + if (entries.length > maxEntries) { + entries.shift() + } + notify() + }, + + clear() { + entries.length = 0 + notify() + }, + + getEntries(filter) { + return entries.filter((entry) => { + if (filter?.level && entry.level !== filter.level) + return false + if (filter?.scope && !entry.scope?.includes(filter.scope)) + return false + return true + }) + }, + + subscribe(callback) { + subscribers.add(callback) + return () => subscribers.delete(callback) + }, + } +} +``` + +### 6. Integration with DevTools Context + +```typescript +// In packages/core/src/node/context.ts + +import { createNodeLogger, createLogCollector } from '@vitejs/devtools-kit/utils/logger' + +export async function createDevToolsContext(...) { + const logCollector = createLogCollector() + + const logger = createNodeLogger({ + scope: 'vite-devtools', + onLog: (entry) => logCollector.add(entry), + }) + + const context: DevToolsNodeContext = { + // ... existing properties + logger, + logCollector, // Expose for the Logs panel + } + + // Usage in setup + logger.info('DevTools context created', { cwd, mode: viteConfig.command }) + + // For plugins + for (const plugin of plugins) { + const pluginLogger = logger.child(`plugin:${plugin.name}`) + try { + await plugin.devtools?.setup?.(context, pluginLogger) + } catch (error) { + pluginLogger.error(error as Error) + throw error + } + } +} +``` + +### 7. Error Toast System (Client) + +```typescript +// packages/kit/src/client/toast.ts + +import type { LogEntry } from '../utils/logger-types' + +export interface ToastOptions { + duration?: number + type?: 'info' | 'warn' | 'error' | 'success' +} + +export interface ToastManager { + show: (message: string, options?: ToastOptions) => void + showFromLog: (entry: LogEntry) => void + dismiss: (id: string) => void + dismissAll: () => void +} + +// Implementation would integrate with the UI framework (Vue) +// This is just the interface definition +``` + +## Migration Guide + +### Before + +```typescript +// packages/core/src/node/ws.ts +console.warn('[Vite DevTools] Client authentication is disabled...') +console.log(color`${MARK_INFO} Websocket client connected...`) +console.error(c.red`⬢ RPC error on executing...`) +``` + +### After + +```typescript +// packages/core/src/node/ws.ts +import { logger } from '@vitejs/devtools-kit/utils/logger' + +const wsLogger = logger.child('ws') + +wsLogger.warn('Client authentication is disabled. Any browser can connect to the devtools.') +wsLogger.info('Websocket client connected', { id: meta.id, trusted: meta.isTrusted }) +wsLogger.error('RPC error on executing', { method: name, error }) +``` + +## Exports + +The logger should be exported from `@vitejs/devtools-kit`: + +```typescript +// packages/kit/src/index.ts +export { createLogCollector, createLogger } from './utils/logger' +export type { LogCollector, LogEntry, Logger, LoggerOptions, LogLevel } from './utils/logger-types' +``` + +And available via subpath: +```typescript +// For tree-shaking when only logger is needed +import { createLogger } from '@vitejs/devtools-kit/utils/logger' +``` + +## Configuration + +### Environment Variables + +```bash +# Set global log level +VITE_DEVTOOLS_LOG_LEVEL=debug + +# Enable specific scopes (similar to DEBUG env var pattern) +VITE_DEVTOOLS_LOG_SCOPES=vite-devtools:ws,vite-devtools:rpc +``` + +### Runtime Configuration + +```typescript +// In vite.config.ts +export default defineConfig({ + plugins: [ + devtools({ + logger: { + level: 'debug', + timestamps: true, + } + }) + ] +}) +``` + +## Implementation Plan + +### Phase 1: Core Logger (packages/kit) +1. Create logger types in `packages/kit/src/utils/logger-types.ts` +2. Implement Node logger in `packages/kit/src/utils/logger-node.ts` +3. Implement Client logger in `packages/kit/src/utils/logger-client.ts` +4. Create log collector for aggregation +5. Export from kit package + +### Phase 2: Integration (packages/core) +1. Add logger to `DevToolsNodeContext` +2. Migrate `packages/core/src/node/*.ts` console calls +3. Add `onLog` handler for log aggregation + +### Phase 3: Client Integration +1. Migrate `packages/core/src/client/**/*.ts` console calls +2. Implement toast notifications for errors +3. Create Logs panel UI component + +### Phase 4: Cleanup +1. Remove `// TODO: A unified logger API` comments +2. Enable the Logs dock panel (`isHidden: false`) +3. Update documentation + +## Alternatives Considered + +### 1. Use existing library (pino, winston, consola) +- **Pros**: Battle-tested, feature-rich +- **Cons**: Bundle size, over-engineered for devtools needs, may not work well in both Node and browser + +### 2. Extend `obug`/`createDebug` +- **Pros**: Already used in the project +- **Cons**: Debug-only, no log levels, not designed for production logging + +### 3. Keep console.* with standardized prefixes +- **Pros**: Simple, no new dependencies +- **Cons**: No log levels, no aggregation, no structured data + +## Open Questions + +1. Should logs be persisted to disk in dev mode? +2. Should we integrate with Vite's own logger? +3. What should the max buffer size be for log aggregation? +4. Should we support custom log formatters? + +## References + +- [Vite Logger](https://vite.dev/guide/api-javascript.html#custom-logger) +- [consola](https://github.com/unjs/consola) - Similar unified logging approach +- Related TODOs in codebase: + - `packages/core/src/client/webcomponents/state/setup-script.ts:20` + - `packages/core/src/node/host-docks.ts:34` diff --git a/packages/kit/package.json b/packages/kit/package.json index 89121eaa..bb456255 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -24,6 +24,9 @@ "./utils/events": "./dist/utils/events.mjs", "./utils/nanoid": "./dist/utils/nanoid.mjs", "./utils/shared-state": "./dist/utils/shared-state.mjs", + "./utils/logger": "./dist/utils/logger.mjs", + "./utils/logger-node": "./dist/utils/logger-node.mjs", + "./utils/logger-client": "./dist/utils/logger-client.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", diff --git a/packages/kit/src/utils/log-collector.ts b/packages/kit/src/utils/log-collector.ts new file mode 100644 index 00000000..076c74eb --- /dev/null +++ b/packages/kit/src/utils/log-collector.ts @@ -0,0 +1,102 @@ +/** + * Log Collector + * + * Collects and stores log entries for display in the DevTools Logs panel. + * Supports filtering, subscribing to updates, and auto-pruning old entries. + */ + +import type { LogCollector, LogEntry, LogFilter, LogLevel } from './logger-types' + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + silent: 4, +} + +export interface LogCollectorOptions { + /** Maximum number of entries to keep. Default: 1000 */ + maxEntries?: number +} + +export function createLogCollector(options: LogCollectorOptions = {}): LogCollector { + const { maxEntries = 1000 } = options + + const entries: LogEntry[] = [] + const subscribers = new Set<(entries: readonly LogEntry[]) => void>() + + function notify(): void { + const snapshot = [...entries] as readonly LogEntry[] + subscribers.forEach(cb => cb(snapshot)) + } + + const collector: LogCollector = { + get entries(): readonly LogEntry[] { + return entries + }, + + maxEntries, + + add(entry: LogEntry): void { + entries.push(entry) + + // Prune old entries if over limit + while (entries.length > maxEntries) { + entries.shift() + } + + notify() + }, + + clear(): void { + entries.length = 0 + notify() + }, + + getEntries(filter?: LogFilter): LogEntry[] { + if (!filter) { + return [...entries] + } + + return entries.filter((entry) => { + // Filter by level (entry level must be >= filter level) + if (filter.level) { + const filterPriority = LOG_LEVEL_PRIORITY[filter.level] + const entryPriority = LOG_LEVEL_PRIORITY[entry.level] + if (entryPriority < filterPriority) { + return false + } + } + + // Filter by scope (partial match) + if (filter.scope && entry.scope) { + if (!entry.scope.includes(filter.scope)) { + return false + } + } + + // Filter by timestamp + if (filter.since && entry.timestamp < filter.since) { + return false + } + + return true + }) + }, + + subscribe(callback: (entries: readonly LogEntry[]) => void): () => void { + subscribers.add(callback) + + // Immediately call with current entries + callback([...entries] as readonly LogEntry[]) + + // Return unsubscribe function + return () => { + subscribers.delete(callback) + } + }, + } + + return collector +} diff --git a/packages/kit/src/utils/logger-client.ts b/packages/kit/src/utils/logger-client.ts new file mode 100644 index 00000000..7b5e82bb --- /dev/null +++ b/packages/kit/src/utils/logger-client.ts @@ -0,0 +1,131 @@ +/** + * Logger Implementation for Browser/Client + * + * A lightweight, scoped logger for client-side Vite DevTools code. + * Supports styled console output, log levels, and log aggregation. + */ + +import type { LogEntry, Logger, LoggerOptions, LogLevel } from './logger-types' + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + silent: 4, +} + +const LEVEL_STYLES: Record, string> = { + debug: 'color: #9ca3af; font-weight: normal', + info: 'color: #3b82f6; font-weight: normal', + warn: 'color: #f59e0b; font-weight: bold', + error: 'color: #ef4444; font-weight: bold', +} + +const LEVEL_ICONS: Record, string> = { + debug: '🔍', + info: 'ℹ️', + warn: '⚠️', + error: '❌', +} + +export function createClientLogger(options: LoggerOptions = {}): Logger { + const { + level = 'info', + scope, + timestamps = false, + onLog, + } = options + + let currentLevel = level + + function shouldLog(msgLevel: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[msgLevel] >= LOG_LEVEL_PRIORITY[currentLevel] + } + + function log(entry: LogEntry): void { + // Always call onLog for aggregation, regardless of level + onLog?.(entry) + + if (!shouldLog(entry.level) || entry.level === 'silent') { + return + } + + const style = LEVEL_STYLES[entry.level] + const icon = LEVEL_ICONS[entry.level] + const scopeText = entry.scope ? `[${entry.scope}]` : '[vite-devtools]' + const time = timestamps ? `${new Date(entry.timestamp).toISOString()} ` : '' + + // Build the formatted message + const prefix = `${time}${icon} ${scopeText}` + + const method = entry.level === 'debug' ? 'log' : entry.level + + // Use styled console output + // eslint-disable-next-line no-console + console[method]( + `%c${prefix}%c ${entry.message}`, + style, + 'color: inherit; font-weight: normal', + ) + + // Log error stack if present + if (entry.error?.stack) { + console.error(entry.error) + } + + // Log metadata if present + if (entry.meta && Object.keys(entry.meta).length > 0) { + // eslint-disable-next-line no-console + console.log(' ↳', entry.meta) + } + } + + function createLogMethod(level: Exclude) { + return (message: string | Error, meta?: Record) => { + const isError = message instanceof Error + const entry: LogEntry = { + level, + message: isError ? message.message : message, + timestamp: Date.now(), + scope, + meta, + error: isError ? message : undefined, + } + log(entry) + } + } + + const logger: Logger = { + debug: createLogMethod('debug'), + info: createLogMethod('info'), + warn: createLogMethod('warn'), + error: createLogMethod('error'), + + child(childScope: string): Logger { + const newScope = scope ? `${scope}:${childScope}` : childScope + return createClientLogger({ + level: currentLevel, + scope: newScope, + timestamps, + onLog, + }) + }, + + setLevel(newLevel: LogLevel) { + currentLevel = newLevel + }, + + getLevel(): LogLevel { + return currentLevel + }, + } + + return logger +} + +/** + * Default logger instance for convenience. + * Use `createClientLogger()` for custom configuration. + */ +export const logger = createClientLogger({ scope: 'vite-devtools' }) diff --git a/packages/kit/src/utils/logger-node.ts b/packages/kit/src/utils/logger-node.ts new file mode 100644 index 00000000..d39fe1c3 --- /dev/null +++ b/packages/kit/src/utils/logger-node.ts @@ -0,0 +1,163 @@ +/** + * Logger Implementation for Node.js + * + * A lightweight, scoped logger for server-side Vite DevTools code. + * Supports colored output, log levels, and log aggregation. + */ + +import type { LogEntry, Logger, LoggerOptions, LogLevel } from './logger-types' +import process from 'node:process' + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + silent: 4, +} + +// ANSI color codes for terminal output +const COLORS = { + reset: '\x1B[0m', + gray: '\x1B[90m', + cyan: '\x1B[36m', + blue: '\x1B[34m', + yellow: '\x1B[33m', + red: '\x1B[31m', + bold: '\x1B[1m', +} as const + +const LEVEL_CONFIG: Record, { icon: string, color: string }> = { + debug: { icon: '🔍', color: COLORS.gray }, + info: { icon: 'ℹ', color: COLORS.blue }, + warn: { icon: '⚠', color: COLORS.yellow }, + error: { icon: '✖', color: COLORS.red }, +} + +function colorize(text: string, color: string): string { + return `${color}${text}${COLORS.reset}` +} + +function getEnvLogLevel(): LogLevel | undefined { + const envLevel = process.env.VITE_DEVTOOLS_LOG_LEVEL?.toLowerCase() + if (envLevel && envLevel in LOG_LEVEL_PRIORITY) { + return envLevel as LogLevel + } + return undefined +} + +export function createNodeLogger(options: LoggerOptions = {}): Logger { + const { + level = getEnvLogLevel() ?? 'info', + scope, + timestamps = false, + onLog, + } = options + + let currentLevel = level + + function shouldLog(msgLevel: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[msgLevel] >= LOG_LEVEL_PRIORITY[currentLevel] + } + + function formatTimestamp(): string { + return colorize(new Date().toISOString(), COLORS.gray) + } + + function formatScope(s: string): string { + return colorize(`[${s}]`, COLORS.cyan) + } + + function log(entry: LogEntry): void { + // Always call onLog for aggregation, regardless of level + onLog?.(entry) + + if (!shouldLog(entry.level) || entry.level === 'silent') { + return + } + + const config = LEVEL_CONFIG[entry.level] + const parts: string[] = [] + + // Timestamp (optional) + if (timestamps) { + parts.push(formatTimestamp()) + } + + // Icon with color + parts.push(colorize(config.icon, config.color)) + + // Scope + if (entry.scope) { + parts.push(formatScope(entry.scope)) + } + + // Message + parts.push(entry.message) + + // Output + const output = parts.join(' ') + const method = entry.level === 'debug' ? 'log' : entry.level + // eslint-disable-next-line no-console + console[method](output) + + // Error stack trace + if (entry.error?.stack) { + console.error(colorize(entry.error.stack, COLORS.red)) + } + + // Metadata + if (entry.meta && Object.keys(entry.meta).length > 0) { + // eslint-disable-next-line no-console + console.log(colorize(' ↳', COLORS.gray), entry.meta) + } + } + + function createLogMethod(level: Exclude) { + return (message: string | Error, meta?: Record) => { + const isError = message instanceof Error + const entry: LogEntry = { + level, + message: isError ? message.message : message, + timestamp: Date.now(), + scope, + meta, + error: isError ? message : undefined, + } + log(entry) + } + } + + const logger: Logger = { + debug: createLogMethod('debug'), + info: createLogMethod('info'), + warn: createLogMethod('warn'), + error: createLogMethod('error'), + + child(childScope: string): Logger { + const newScope = scope ? `${scope}:${childScope}` : childScope + return createNodeLogger({ + level: currentLevel, + scope: newScope, + timestamps, + onLog, + }) + }, + + setLevel(newLevel: LogLevel) { + currentLevel = newLevel + }, + + getLevel(): LogLevel { + return currentLevel + }, + } + + return logger +} + +/** + * Default logger instance for convenience. + * Use `createNodeLogger()` for custom configuration. + */ +export const logger = createNodeLogger({ scope: 'vite-devtools' }) diff --git a/packages/kit/src/utils/logger-types.ts b/packages/kit/src/utils/logger-types.ts new file mode 100644 index 00000000..210ae4eb --- /dev/null +++ b/packages/kit/src/utils/logger-types.ts @@ -0,0 +1,79 @@ +/** + * Logger Types + * + * Shared type definitions for the unified logging API. + */ + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent' + +export interface LogEntry { + /** Log level */ + level: LogLevel + /** Log message */ + message: string + /** Unix timestamp in milliseconds */ + timestamp: number + /** Logger scope/namespace (e.g., 'rpc', 'ws', 'client') */ + scope?: string + /** Additional structured metadata */ + meta?: Record + /** Error object if this is an error log */ + error?: Error +} + +export interface LoggerOptions { + /** Minimum log level to output. Default: 'info' */ + level?: LogLevel + /** Scope/namespace for the logger (e.g., 'rpc', 'ws', 'client') */ + scope?: string + /** Whether to include timestamps in output. Default: false */ + timestamps?: boolean + /** Custom log handler for aggregation/forwarding */ + onLog?: (entry: LogEntry) => void +} + +export interface Logger { + /** Log debug message (development only) */ + debug: (message: string, meta?: Record) => void + /** Log info message */ + info: (message: string, meta?: Record) => void + /** Log warning message */ + warn: (message: string, meta?: Record) => void + /** Log error message or Error object */ + error: (message: string | Error, meta?: Record) => void + + /** Create a child logger with a sub-scope */ + child: (scope: string) => Logger + + /** Update logger level at runtime */ + setLevel: (level: LogLevel) => void + + /** Get current log level */ + getLevel: () => LogLevel +} + +export interface LogCollector { + /** All collected log entries */ + readonly entries: readonly LogEntry[] + /** Maximum number of entries to keep */ + readonly maxEntries: number + + /** Add a log entry */ + add: (entry: LogEntry) => void + /** Clear all entries */ + clear: () => void + /** Get filtered entries */ + getEntries: (filter?: LogFilter) => LogEntry[] + + /** Subscribe to log updates */ + subscribe: (callback: (entries: readonly LogEntry[]) => void) => () => void +} + +export interface LogFilter { + /** Filter by log level */ + level?: LogLevel + /** Filter by scope (partial match) */ + scope?: string + /** Filter entries after this timestamp */ + since?: number +} diff --git a/packages/kit/src/utils/logger.test.ts b/packages/kit/src/utils/logger.test.ts new file mode 100644 index 00000000..5df4c270 --- /dev/null +++ b/packages/kit/src/utils/logger.test.ts @@ -0,0 +1,185 @@ +/** + * Logger Tests + */ + +import type { LogEntry } from './logger-types' +import { describe, expect, it, vi } from 'vitest' +import { createLogCollector } from './log-collector' +import { createNodeLogger } from './logger-node' + +describe('createNodeLogger', () => { + it('should create a logger with default options', () => { + const logger = createNodeLogger() + expect(logger).toBeDefined() + expect(logger.info).toBeInstanceOf(Function) + expect(logger.warn).toBeInstanceOf(Function) + expect(logger.error).toBeInstanceOf(Function) + expect(logger.debug).toBeInstanceOf(Function) + }) + + it('should respect log level', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const logger = createNodeLogger({ level: 'warn' }) + + logger.debug('debug message') + logger.info('info message') + + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should call onLog callback for all levels', () => { + const entries: LogEntry[] = [] + const logger = createNodeLogger({ + level: 'silent', // Don't output to console + onLog: entry => entries.push(entry), + }) + + logger.debug('debug') + logger.info('info') + logger.warn('warn') + logger.error('error') + + expect(entries).toHaveLength(4) + expect(entries.map(e => e.level)).toEqual(['debug', 'info', 'warn', 'error']) + }) + + it('should create child logger with combined scope', () => { + const entries: LogEntry[] = [] + const logger = createNodeLogger({ + scope: 'parent', + level: 'silent', + onLog: entry => entries.push(entry), + }) + + const child = logger.child('child') + child.info('message') + + expect(entries[0].scope).toBe('parent:child') + }) + + it('should handle Error objects', () => { + const entries: LogEntry[] = [] + const logger = createNodeLogger({ + level: 'silent', + onLog: entry => entries.push(entry), + }) + + const error = new Error('test error') + logger.error(error) + + expect(entries[0].message).toBe('test error') + expect(entries[0].error).toBe(error) + }) + + it('should include metadata', () => { + const entries: LogEntry[] = [] + const logger = createNodeLogger({ + level: 'silent', + onLog: entry => entries.push(entry), + }) + + logger.info('message', { key: 'value' }) + + expect(entries[0].meta).toEqual({ key: 'value' }) + }) + + it('should allow changing log level at runtime', () => { + const logger = createNodeLogger({ level: 'info' }) + + expect(logger.getLevel()).toBe('info') + + logger.setLevel('debug') + expect(logger.getLevel()).toBe('debug') + }) +}) + +describe('createLogCollector', () => { + it('should collect log entries', () => { + const collector = createLogCollector() + + collector.add({ + level: 'info', + message: 'test', + timestamp: Date.now(), + }) + + expect(collector.entries).toHaveLength(1) + }) + + it('should respect maxEntries limit', () => { + const collector = createLogCollector({ maxEntries: 3 }) + + for (let i = 0; i < 5; i++) { + collector.add({ + level: 'info', + message: `message ${i}`, + timestamp: Date.now(), + }) + } + + expect(collector.entries).toHaveLength(3) + expect(collector.entries[0].message).toBe('message 2') + expect(collector.entries[2].message).toBe('message 4') + }) + + it('should filter entries by level', () => { + const collector = createLogCollector() + + collector.add({ level: 'debug', message: 'debug', timestamp: Date.now() }) + collector.add({ level: 'info', message: 'info', timestamp: Date.now() }) + collector.add({ level: 'warn', message: 'warn', timestamp: Date.now() }) + collector.add({ level: 'error', message: 'error', timestamp: Date.now() }) + + const warnings = collector.getEntries({ level: 'warn' }) + expect(warnings).toHaveLength(2) // warn and error + }) + + it('should filter entries by scope', () => { + const collector = createLogCollector() + + collector.add({ level: 'info', message: 'a', timestamp: Date.now(), scope: 'rpc' }) + collector.add({ level: 'info', message: 'b', timestamp: Date.now(), scope: 'rpc:call' }) + collector.add({ level: 'info', message: 'c', timestamp: Date.now(), scope: 'ws' }) + + const rpcLogs = collector.getEntries({ scope: 'rpc' }) + expect(rpcLogs).toHaveLength(2) + }) + + it('should notify subscribers on add', () => { + const collector = createLogCollector() + const callback = vi.fn() + + collector.subscribe(callback) + + // Called immediately with current entries + expect(callback).toHaveBeenCalledWith([]) + + collector.add({ level: 'info', message: 'test', timestamp: Date.now() }) + + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('should allow unsubscribing', () => { + const collector = createLogCollector() + const callback = vi.fn() + + const unsubscribe = collector.subscribe(callback) + unsubscribe() + + collector.add({ level: 'info', message: 'test', timestamp: Date.now() }) + + // Only called once (initial call) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should clear all entries', () => { + const collector = createLogCollector() + + collector.add({ level: 'info', message: 'test', timestamp: Date.now() }) + expect(collector.entries).toHaveLength(1) + + collector.clear() + expect(collector.entries).toHaveLength(0) + }) +}) diff --git a/packages/kit/src/utils/logger.ts b/packages/kit/src/utils/logger.ts new file mode 100644 index 00000000..f942c037 --- /dev/null +++ b/packages/kit/src/utils/logger.ts @@ -0,0 +1,65 @@ +/** + * Unified Logger API + * + * Entry point for the Vite DevTools logging system. + * Automatically selects the appropriate logger implementation based on environment. + * + * @example + * ```ts + * import { createLogger } from '@vitejs/devtools-kit/utils/logger' + * + * const logger = createLogger({ scope: 'my-plugin' }) + * logger.info('Plugin initialized') + * logger.debug('Debug info', { config }) + * logger.warn('Deprecated option used') + * logger.error(new Error('Something went wrong')) + * + * // Create child loggers for sub-components + * const rpcLogger = logger.child('rpc') + * rpcLogger.info('RPC connected') // [my-plugin:rpc] RPC connected + * ``` + */ + +// Re-export collector +export { createLogCollector } from './log-collector' + +export type { LogCollectorOptions } from './log-collector' +export { logger as clientLogger, createClientLogger } from './logger-client' + +// Environment-specific exports +// These are separate so bundlers can tree-shake the unused implementation + +export { createNodeLogger, logger as nodeLogger } from './logger-node' +// Re-export types +export type { + LogCollector, + LogEntry, + LogFilter, + Logger, + LoggerOptions, + LogLevel, +} from './logger-types' + +/** + * Create a logger instance. + * + * In Node.js: Uses colored terminal output + * In Browser: Uses styled console output + * + * @param options - Logger configuration options + * @returns Logger instance + */ +export function createLogger(options?: import('./logger-types').LoggerOptions): import('./logger-types').Logger { + // Check for browser environment + if (typeof window !== 'undefined') { + // Dynamic import for tree-shaking in Node bundles + // eslint-disable-next-line ts/no-require-imports + const { createClientLogger } = require('./logger-client') + return createClientLogger(options) + } + else { + // eslint-disable-next-line ts/no-require-imports + const { createNodeLogger } = require('./logger-node') + return createNodeLogger(options) + } +} diff --git a/packages/kit/tsdown.config.ts b/packages/kit/tsdown.config.ts index 1d976f3d..d62da0d0 100644 --- a/packages/kit/tsdown.config.ts +++ b/packages/kit/tsdown.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ 'utils/events': 'src/utils/events.ts', 'utils/nanoid': 'src/utils/nanoid.ts', 'utils/shared-state': 'src/utils/shared-state.ts', + 'utils/logger': 'src/utils/logger.ts', + 'utils/logger-node': 'src/utils/logger-node.ts', + 'utils/logger-client': 'src/utils/logger-client.ts', 'client': 'src/client/index.ts', }, exports: true,