diff --git a/.changeset/fresh-mails-bow.md b/.changeset/fresh-mails-bow.md new file mode 100644 index 00000000..b1401098 --- /dev/null +++ b/.changeset/fresh-mails-bow.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-vite': patch +--- + +Preserve printf-style server log formatting when piping logs into the browser console. diff --git a/packages/devtools-vite/src/virtual-console.test.ts b/packages/devtools-vite/src/virtual-console.test.ts index 31788c27..952d4927 100644 --- a/packages/devtools-vite/src/virtual-console.test.ts +++ b/packages/devtools-vite/src/virtual-console.test.ts @@ -2,6 +2,9 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { generateConsolePipeCode } from './virtual-console' const TEST_VITE_URL = 'http://localhost:5173' +const SERVER_PREFIX = '%c[Server]%c' +const SERVER_PREFIX_STYLE = 'color: #9333ea; font-weight: bold;' +const SERVER_RESET_STYLE = 'color: inherit;' afterEach(() => { vi.useRealTimers() @@ -9,11 +12,17 @@ afterEach(() => { delete (window as any).__TSD_CONSOLE_PIPE_INITIALIZED__ }) -function setupWarnConsolePipe() { - const originalWarn = console.warn - const originalWarnMock = vi.fn() +function setupConsolePipe( + levels: Parameters[0], +) { + const originalConsoleMethods: Partial> = {} + const consoleMocks: Record> = {} const fetchMock = vi.fn().mockResolvedValue(undefined) const eventSourceUrls: Array = [] + const eventSources: Array<{ + onmessage: ((event: MessageEvent) => void) | null + onerror: (() => void) | null + }> = [] class MockEventSource { onmessage: ((event: MessageEvent) => void) | null = null @@ -21,31 +30,63 @@ function setupWarnConsolePipe() { constructor(url: string) { eventSourceUrls.push(url) + eventSources.push(this) } } - console.warn = originalWarnMock + for (const level of levels) { + originalConsoleMethods[level] = console[level] + consoleMocks[level] = vi.fn() + console[level] = consoleMocks[level] as typeof console.log + } + vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('EventSource', MockEventSource) - const code = generateConsolePipeCode(['warn'], TEST_VITE_URL) + const code = generateConsolePipeCode(levels, TEST_VITE_URL) new Function(code)() return { + consoleMocks, + eventSources, eventSourceUrls, fetchMock, - originalWarnMock, restore: () => { - console.warn = originalWarn + for (const level of levels) { + const original = originalConsoleMethods[level] + if (original) { + console[level] = original + } + } }, } } +function setupWarnConsolePipe() { + const setup = setupConsolePipe(['warn']) + + return { + ...setup, + originalWarnMock: setup.consoleMocks.warn, + } +} + function getFirstFetchBody(fetchMock: ReturnType) { const [, init] = fetchMock.mock.calls[0]! return JSON.parse(init.body) } +function dispatchServerEntries( + eventSource: { onmessage: ((event: MessageEvent) => void) | null }, + entries: Array<{ level: string; args: Array }>, +) { + eventSource.onmessage?.( + new MessageEvent('message', { + data: JSON.stringify({ entries }), + }), + ) +} + describe('virtual-console', () => { test('generates inline code with specified levels', () => { const code = generateConsolePipeCode(['log', 'error'], TEST_VITE_URL) @@ -68,6 +109,120 @@ describe('virtual-console', () => { expect(code).toContain("new EventSource('/__tsd/console-pipe/sse')") }) + test('preserves server log format substitutions', () => { + const { consoleMocks, eventSources, restore } = setupConsolePipe(['log']) + + try { + dispatchServerEntries(eventSources[0]!, [ + { + level: 'log', + args: ['%s info GET %s %d', 'ts', '/route', 200], + }, + ]) + + expect(consoleMocks.log).toHaveBeenCalledWith( + SERVER_PREFIX + ' %s info GET %s %d', + SERVER_PREFIX_STYLE, + SERVER_RESET_STYLE, + 'ts', + '/route', + 200, + ) + } finally { + restore() + } + }) + + test('preserves server log css format substitutions', () => { + const { consoleMocks, eventSources, restore } = setupConsolePipe(['log']) + + try { + dispatchServerEntries(eventSources[0]!, [ + { + level: 'log', + args: ['%cstarted', 'color: green'], + }, + ]) + + expect(consoleMocks.log).toHaveBeenCalledWith( + SERVER_PREFIX + ' %cstarted', + SERVER_PREFIX_STYLE, + SERVER_RESET_STYLE, + 'color: green', + ) + } finally { + restore() + } + }) + + test('preserves empty server log format substitutions', () => { + const { consoleMocks, eventSources, restore } = setupConsolePipe(['log']) + + try { + dispatchServerEntries(eventSources[0]!, [ + { + level: 'log', + args: ['%s%s%s', '', ' ', 'ok'], + }, + ]) + + expect(consoleMocks.log).toHaveBeenCalledWith( + SERVER_PREFIX + ' %s%s%s', + SERVER_PREFIX_STYLE, + SERVER_RESET_STYLE, + '', + ' ', + 'ok', + ) + } finally { + restore() + } + }) + + test('keeps plain server log prefix arguments unchanged', () => { + const { consoleMocks, eventSources, restore } = setupConsolePipe(['warn']) + + try { + dispatchServerEntries(eventSources[0]!, [ + { + level: 'warn', + args: ['plain message'], + }, + ]) + + expect(consoleMocks.warn).toHaveBeenCalledWith( + SERVER_PREFIX, + SERVER_PREFIX_STYLE, + SERVER_RESET_STYLE, + 'plain message', + ) + } finally { + restore() + } + }) + + test('does not treat non-string first server log args as format strings', () => { + const { consoleMocks, eventSources, restore } = setupConsolePipe(['log']) + + try { + dispatchServerEntries(eventSources[0]!, [ + { + level: 'log', + args: [{ a: 1 }], + }, + ]) + + expect(consoleMocks.log).toHaveBeenCalledWith( + SERVER_PREFIX, + SERVER_PREFIX_STYLE, + SERVER_RESET_STYLE, + { a: 1 }, + ) + } finally { + restore() + } + }) + test('includes environment detection', () => { const code = generateConsolePipeCode(['log'], TEST_VITE_URL) diff --git a/packages/devtools-vite/src/virtual-console.ts b/packages/devtools-vite/src/virtual-console.ts index 27bb6b39..2054c1c9 100644 --- a/packages/devtools-vite/src/virtual-console.ts +++ b/packages/devtools-vite/src/virtual-console.ts @@ -274,7 +274,7 @@ export function generateConsolePipeCode( // CLIENT ONLY: Listen for server console logs via SSE if (!isServer) { // Transform server log args - strip ANSI codes and convert source paths to clickable URLs - function transformServerLogArgs(args) { + function transformServerLogArgs(args, preserveEmptyStrings) { var escChar = String.fromCharCode(27); var transformed = []; @@ -297,7 +297,7 @@ export function generateConsolePipeCode( return window.location.origin + '/__tsd/open-source?source=' + encodeURIComponent(match); }); - if (cleaned.trim()) { + if (preserveEmptyStrings || cleaned.trim()) { transformed.push(cleaned); } } else { @@ -308,6 +308,21 @@ export function generateConsolePipeCode( return transformed; } + function hasConsoleFormatSubstitution(arg) { + return typeof arg === 'string' && /(^|[^%])%[sdifocOj]/.test(arg); + } + + function isEnhancedLogPrefix(arg) { + return typeof arg === 'string' && + arg.indexOf('LOG') !== -1 && + arg.indexOf(String.fromCharCode(10) + ' ' + String.fromCharCode(8594) + ' ') !== -1; + } + + function getConsoleFormatIndex(args) { + if (hasConsoleFormatSubstitution(args[0])) return 0; + return isEnhancedLogPrefix(args[0]) && hasConsoleFormatSubstitution(args[1]) ? 1 : -1; + } + var eventSource = new EventSource('/__tsd/console-pipe/sse'); eventSource.onmessage = function(event) { @@ -316,12 +331,21 @@ export function generateConsolePipeCode( if (data.entries) { for (var m = 0; m < data.entries.length; m++) { var entry = data.entries[m]; - var transformedArgs = transformServerLogArgs(entry.args); + var formatIndex = getConsoleFormatIndex(entry.args); + var shouldPreserveFormat = formatIndex !== -1; + var transformedArgs = transformServerLogArgs(entry.args, shouldPreserveFormat); var prefix = '%c[Server]%c'; var prefixStyle = 'color: #9333ea; font-weight: bold;'; var resetStyle = 'color: inherit;'; var logMethod = originalConsole[entry.level] || originalConsole.log; - logMethod.apply(console, [prefix, prefixStyle, resetStyle].concat(transformedArgs)); + if (shouldPreserveFormat) { + logMethod.apply( + console, + [prefix + ' ' + transformedArgs.slice(0, formatIndex + 1).join(''), prefixStyle, resetStyle].concat(transformedArgs.slice(formatIndex + 1)) + ); + } else { + logMethod.apply(console, [prefix, prefixStyle, resetStyle].concat(transformedArgs)); + } } } } catch (err) {