Skip to content

Commit 2cc1dda

Browse files
committed
Preserve printf-style format substitution when piping server logs to browser console
1 parent e026667 commit 2cc1dda

3 files changed

Lines changed: 195 additions & 11 deletions

File tree

.changeset/fresh-mails-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/devtools-vite': patch
3+
---
4+
5+
Preserve printf-style server log formatting when piping logs into the browser console.

packages/devtools-vite/src/virtual-console.test.ts

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,91 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
22
import { generateConsolePipeCode } from './virtual-console'
33

44
const TEST_VITE_URL = 'http://localhost:5173'
5+
const SERVER_PREFIX = '%c[Server]%c'
6+
const SERVER_PREFIX_STYLE = 'color: #9333ea; font-weight: bold;'
7+
const SERVER_RESET_STYLE = 'color: inherit;'
58

69
afterEach(() => {
710
vi.useRealTimers()
811
vi.unstubAllGlobals()
912
delete (window as any).__TSD_CONSOLE_PIPE_INITIALIZED__
1013
})
1114

12-
function setupWarnConsolePipe() {
13-
const originalWarn = console.warn
14-
const originalWarnMock = vi.fn()
15+
function setupConsolePipe(
16+
levels: Parameters<typeof generateConsolePipeCode>[0],
17+
) {
18+
const originalConsoleMethods: Partial<Record<string, typeof console.log>> = {}
19+
const consoleMocks: Record<string, ReturnType<typeof vi.fn>> = {}
1520
const fetchMock = vi.fn().mockResolvedValue(undefined)
1621
const eventSourceUrls: Array<string> = []
22+
const eventSources: Array<{
23+
onmessage: ((event: MessageEvent) => void) | null
24+
onerror: (() => void) | null
25+
}> = []
1726

1827
class MockEventSource {
1928
onmessage: ((event: MessageEvent) => void) | null = null
2029
onerror: (() => void) | null = null
2130

2231
constructor(url: string) {
2332
eventSourceUrls.push(url)
33+
eventSources.push(this)
2434
}
2535
}
2636

27-
console.warn = originalWarnMock
37+
for (const level of levels) {
38+
originalConsoleMethods[level] = console[level]
39+
consoleMocks[level] = vi.fn()
40+
console[level] = consoleMocks[level] as typeof console.log
41+
}
42+
2843
vi.stubGlobal('fetch', fetchMock)
2944
vi.stubGlobal('EventSource', MockEventSource)
3045

31-
const code = generateConsolePipeCode(['warn'], TEST_VITE_URL)
46+
const code = generateConsolePipeCode(levels, TEST_VITE_URL)
3247
new Function(code)()
3348

3449
return {
50+
consoleMocks,
51+
eventSources,
3552
eventSourceUrls,
3653
fetchMock,
37-
originalWarnMock,
3854
restore: () => {
39-
console.warn = originalWarn
55+
for (const level of levels) {
56+
const original = originalConsoleMethods[level]
57+
if (original) {
58+
console[level] = original
59+
}
60+
}
4061
},
4162
}
4263
}
4364

65+
function setupWarnConsolePipe() {
66+
const setup = setupConsolePipe(['warn'])
67+
68+
return {
69+
...setup,
70+
originalWarnMock: setup.consoleMocks.warn,
71+
}
72+
}
73+
4474
function getFirstFetchBody(fetchMock: ReturnType<typeof vi.fn>) {
4575
const [, init] = fetchMock.mock.calls[0]!
4676
return JSON.parse(init.body)
4777
}
4878

79+
function dispatchServerEntries(
80+
eventSource: { onmessage: ((event: MessageEvent) => void) | null },
81+
entries: Array<{ level: string; args: Array<unknown> }>,
82+
) {
83+
eventSource.onmessage?.(
84+
new MessageEvent('message', {
85+
data: JSON.stringify({ entries }),
86+
}),
87+
)
88+
}
89+
4990
describe('virtual-console', () => {
5091
test('generates inline code with specified levels', () => {
5192
const code = generateConsolePipeCode(['log', 'error'], TEST_VITE_URL)
@@ -68,6 +109,120 @@ describe('virtual-console', () => {
68109
expect(code).toContain("new EventSource('/__tsd/console-pipe/sse')")
69110
})
70111

112+
test('preserves server log format substitutions', () => {
113+
const { consoleMocks, eventSources, restore } = setupConsolePipe(['log'])
114+
115+
try {
116+
dispatchServerEntries(eventSources[0]!, [
117+
{
118+
level: 'log',
119+
args: ['%s info GET %s %d', 'ts', '/route', 200],
120+
},
121+
])
122+
123+
expect(consoleMocks.log).toHaveBeenCalledWith(
124+
SERVER_PREFIX + ' %s info GET %s %d',
125+
SERVER_PREFIX_STYLE,
126+
SERVER_RESET_STYLE,
127+
'ts',
128+
'/route',
129+
200,
130+
)
131+
} finally {
132+
restore()
133+
}
134+
})
135+
136+
test('preserves server log css format substitutions', () => {
137+
const { consoleMocks, eventSources, restore } = setupConsolePipe(['log'])
138+
139+
try {
140+
dispatchServerEntries(eventSources[0]!, [
141+
{
142+
level: 'log',
143+
args: ['%cstarted', 'color: green'],
144+
},
145+
])
146+
147+
expect(consoleMocks.log).toHaveBeenCalledWith(
148+
SERVER_PREFIX + ' %cstarted',
149+
SERVER_PREFIX_STYLE,
150+
SERVER_RESET_STYLE,
151+
'color: green',
152+
)
153+
} finally {
154+
restore()
155+
}
156+
})
157+
158+
test('preserves empty server log format substitutions', () => {
159+
const { consoleMocks, eventSources, restore } = setupConsolePipe(['log'])
160+
161+
try {
162+
dispatchServerEntries(eventSources[0]!, [
163+
{
164+
level: 'log',
165+
args: ['%s%s%s', '', ' ', 'ok'],
166+
},
167+
])
168+
169+
expect(consoleMocks.log).toHaveBeenCalledWith(
170+
SERVER_PREFIX + ' %s%s%s',
171+
SERVER_PREFIX_STYLE,
172+
SERVER_RESET_STYLE,
173+
'',
174+
' ',
175+
'ok',
176+
)
177+
} finally {
178+
restore()
179+
}
180+
})
181+
182+
test('keeps plain server log prefix arguments unchanged', () => {
183+
const { consoleMocks, eventSources, restore } = setupConsolePipe(['warn'])
184+
185+
try {
186+
dispatchServerEntries(eventSources[0]!, [
187+
{
188+
level: 'warn',
189+
args: ['plain message'],
190+
},
191+
])
192+
193+
expect(consoleMocks.warn).toHaveBeenCalledWith(
194+
SERVER_PREFIX,
195+
SERVER_PREFIX_STYLE,
196+
SERVER_RESET_STYLE,
197+
'plain message',
198+
)
199+
} finally {
200+
restore()
201+
}
202+
})
203+
204+
test('does not treat non-string first server log args as format strings', () => {
205+
const { consoleMocks, eventSources, restore } = setupConsolePipe(['log'])
206+
207+
try {
208+
dispatchServerEntries(eventSources[0]!, [
209+
{
210+
level: 'log',
211+
args: [{ a: 1 }],
212+
},
213+
])
214+
215+
expect(consoleMocks.log).toHaveBeenCalledWith(
216+
SERVER_PREFIX,
217+
SERVER_PREFIX_STYLE,
218+
SERVER_RESET_STYLE,
219+
{ a: 1 },
220+
)
221+
} finally {
222+
restore()
223+
}
224+
})
225+
71226
test('includes environment detection', () => {
72227
const code = generateConsolePipeCode(['log'], TEST_VITE_URL)
73228

packages/devtools-vite/src/virtual-console.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ export function generateConsolePipeCode(
274274
// CLIENT ONLY: Listen for server console logs via SSE
275275
if (!isServer) {
276276
// Transform server log args - strip ANSI codes and convert source paths to clickable URLs
277-
function transformServerLogArgs(args) {
277+
function transformServerLogArgs(args, preserveEmptyStrings) {
278278
var escChar = String.fromCharCode(27);
279279
var transformed = [];
280280
@@ -297,7 +297,7 @@ export function generateConsolePipeCode(
297297
return window.location.origin + '/__tsd/open-source?source=' + encodeURIComponent(match);
298298
});
299299
300-
if (cleaned.trim()) {
300+
if (preserveEmptyStrings || cleaned.trim()) {
301301
transformed.push(cleaned);
302302
}
303303
} else {
@@ -308,6 +308,21 @@ export function generateConsolePipeCode(
308308
return transformed;
309309
}
310310
311+
function hasConsoleFormatSubstitution(arg) {
312+
return typeof arg === 'string' && /(^|[^%])%[sdifocOj]/.test(arg);
313+
}
314+
315+
function isEnhancedLogPrefix(arg) {
316+
return typeof arg === 'string' &&
317+
arg.indexOf('LOG') !== -1 &&
318+
arg.indexOf(String.fromCharCode(10) + ' ' + String.fromCharCode(8594) + ' ') !== -1;
319+
}
320+
321+
function getConsoleFormatIndex(args) {
322+
if (hasConsoleFormatSubstitution(args[0])) return 0;
323+
return isEnhancedLogPrefix(args[0]) && hasConsoleFormatSubstitution(args[1]) ? 1 : -1;
324+
}
325+
311326
var eventSource = new EventSource('/__tsd/console-pipe/sse');
312327
313328
eventSource.onmessage = function(event) {
@@ -316,12 +331,21 @@ export function generateConsolePipeCode(
316331
if (data.entries) {
317332
for (var m = 0; m < data.entries.length; m++) {
318333
var entry = data.entries[m];
319-
var transformedArgs = transformServerLogArgs(entry.args);
334+
var formatIndex = getConsoleFormatIndex(entry.args);
335+
var shouldPreserveFormat = formatIndex !== -1;
336+
var transformedArgs = transformServerLogArgs(entry.args, shouldPreserveFormat);
320337
var prefix = '%c[Server]%c';
321338
var prefixStyle = 'color: #9333ea; font-weight: bold;';
322339
var resetStyle = 'color: inherit;';
323340
var logMethod = originalConsole[entry.level] || originalConsole.log;
324-
logMethod.apply(console, [prefix, prefixStyle, resetStyle].concat(transformedArgs));
341+
if (shouldPreserveFormat) {
342+
logMethod.apply(
343+
console,
344+
[prefix + ' ' + transformedArgs.slice(0, formatIndex + 1).join(''), prefixStyle, resetStyle].concat(transformedArgs.slice(formatIndex + 1))
345+
);
346+
} else {
347+
logMethod.apply(console, [prefix, prefixStyle, resetStyle].concat(transformedArgs));
348+
}
325349
}
326350
}
327351
} catch (err) {

0 commit comments

Comments
 (0)