Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-mails-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/devtools-vite': patch
---

Preserve printf-style server log formatting when piping logs into the browser console.
169 changes: 162 additions & 7 deletions packages/devtools-vite/src/virtual-console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,91 @@ 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()
vi.unstubAllGlobals()
delete (window as any).__TSD_CONSOLE_PIPE_INITIALIZED__
})

function setupWarnConsolePipe() {
const originalWarn = console.warn
const originalWarnMock = vi.fn()
function setupConsolePipe(
levels: Parameters<typeof generateConsolePipeCode>[0],
) {
const originalConsoleMethods: Partial<Record<string, typeof console.log>> = {}
const consoleMocks: Record<string, ReturnType<typeof vi.fn>> = {}
const fetchMock = vi.fn().mockResolvedValue(undefined)
const eventSourceUrls: Array<string> = []
const eventSources: Array<{
onmessage: ((event: MessageEvent) => void) | null
onerror: (() => void) | null
}> = []

class MockEventSource {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: (() => void) | null = null

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<typeof vi.fn>) {
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<unknown> }>,
) {
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)
Expand All @@ -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)

Expand Down
32 changes: 28 additions & 4 deletions packages/devtools-vite/src/virtual-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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 {
Expand All @@ -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;
}
Comment on lines +321 to +324

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚑ Quick win

Avoid classifying literal % sequences as format substitutions.

getConsoleFormatIndex currently enables the preserve-format branch even when there are no substitution arguments. Literal messages like "CPU at 100%c" can then be treated as format strings and rendered incorrectly after prefix injection. Gate preservation by placeholder count vs available trailing args.

Suggested fix
-    function hasConsoleFormatSubstitution(arg) {
-      return typeof arg === 'string' && /(^|[^%])%[sdifocOj]/.test(arg);
-    }
+    function getConsoleFormatPlaceholderCount(arg) {
+      if (typeof arg !== 'string') return 0;
+      var matches = arg.match(/(^|[^%])%[sdifocOj]/g);
+      return matches ? matches.length : 0;
+    }

     function isEnhancedLogPrefix(arg) {
       return typeof arg === 'string' &&
         arg.indexOf('LOG') !== -1 &&
         arg.indexOf(String.fromCharCode(10) + ' ' + String.fromCharCode(8594) + ' ') !== -1;
     }

+    function hasResolvableConsoleFormatSubstitution(args, formatIndex) {
+      var placeholderCount = getConsoleFormatPlaceholderCount(args[formatIndex]);
+      return placeholderCount > 0 && (args.length - (formatIndex + 1)) >= placeholderCount;
+    }
+
     function getConsoleFormatIndex(args) {
-      if (hasConsoleFormatSubstitution(args[0])) return 0;
-      return isEnhancedLogPrefix(args[0]) && hasConsoleFormatSubstitution(args[1]) ? 1 : -1;
+      if (hasResolvableConsoleFormatSubstitution(args, 0)) return 0;
+      return isEnhancedLogPrefix(args[0]) && hasResolvableConsoleFormatSubstitution(args, 1) ? 1 : -1;
     }

Also applies to: 334-336

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools-vite/src/virtual-console.ts` around lines 321 - 324, The
preserve-format detection in getConsoleFormatIndex is too permissive and can
treat literal percent text like β€œ100%c” as a format string when there are no
matching substitution arguments. Update the logic around getConsoleFormatIndex
and the related preserve-format path to only preserve formatting when the number
of actual console placeholders is supported by the available trailing args, so
literal % sequences are not reclassified after prefix injection.


var eventSource = new EventSource('/__tsd/console-pipe/sse');

eventSource.onmessage = function(event) {
Expand All @@ -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) {
Expand Down