Skip to content

Commit cd077e3

Browse files
committed
fix console piping DOM serialization
1 parent f1844a2 commit cd077e3

3 files changed

Lines changed: 323 additions & 8 deletions

File tree

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+
Avoid piping raw DOM nodes through the console proxy.

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

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,51 @@
1-
import { describe, expect, test } from 'vitest'
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
22
import { generateConsolePipeCode } from './virtual-console'
33

44
const TEST_VITE_URL = 'http://localhost:5173'
55

6+
afterEach(() => {
7+
vi.useRealTimers()
8+
vi.unstubAllGlobals()
9+
delete (window as any).__TSD_CONSOLE_PIPE_INITIALIZED__
10+
})
11+
12+
function setupWarnConsolePipe() {
13+
const originalWarn = console.warn
14+
const originalWarnMock = vi.fn()
15+
const fetchMock = vi.fn().mockResolvedValue(undefined)
16+
const eventSourceUrls: Array<string> = []
17+
18+
class MockEventSource {
19+
onmessage: ((event: MessageEvent) => void) | null = null
20+
onerror: (() => void) | null = null
21+
22+
constructor(url: string) {
23+
eventSourceUrls.push(url)
24+
}
25+
}
26+
27+
console.warn = originalWarnMock
28+
vi.stubGlobal('fetch', fetchMock)
29+
vi.stubGlobal('EventSource', MockEventSource)
30+
31+
const code = generateConsolePipeCode(['warn'], TEST_VITE_URL)
32+
new Function(code)()
33+
34+
return {
35+
eventSourceUrls,
36+
fetchMock,
37+
originalWarnMock,
38+
restore: () => {
39+
console.warn = originalWarn
40+
},
41+
}
42+
}
43+
44+
function getFirstFetchBody(fetchMock: ReturnType<typeof vi.fn>) {
45+
const [, init] = fetchMock.mock.calls[0]!
46+
return JSON.parse(init.body)
47+
}
48+
649
describe('virtual-console', () => {
750
test('generates inline code with specified levels', () => {
851
const code = generateConsolePipeCode(['log', 'error'], TEST_VITE_URL)
@@ -70,4 +113,132 @@ describe('virtual-console', () => {
70113
expect(code).toContain(TEST_VITE_URL)
71114
expect(code).toContain('/__tsd/console-pipe/server')
72115
})
116+
117+
test('serializes DOM elements before sending logs', async () => {
118+
vi.useFakeTimers()
119+
120+
const { eventSourceUrls, fetchMock, originalWarnMock, restore } =
121+
setupWarnConsolePipe()
122+
123+
try {
124+
const button = document.createElement('button')
125+
button.id = 'save'
126+
button.className = 'primary'
127+
button.type = 'button'
128+
button.setAttribute('data-testid', 'save-button')
129+
130+
console.warn('gesture warning', button)
131+
132+
await vi.advanceTimersByTimeAsync(100)
133+
134+
expect(originalWarnMock).toHaveBeenCalledWith('gesture warning', button)
135+
expect(eventSourceUrls).toEqual(['/__tsd/console-pipe/sse'])
136+
expect(fetchMock).toHaveBeenCalledTimes(1)
137+
138+
const body = getFirstFetchBody(fetchMock)
139+
140+
expect(body.entries[0]).toMatchObject({
141+
level: 'warn',
142+
source: 'client',
143+
args: [
144+
'gesture warning',
145+
'<button id="save" class="primary" type="button" data-testid="save-button">',
146+
],
147+
})
148+
} finally {
149+
restore()
150+
}
151+
})
152+
153+
test('serializes circular refs and Error objects before sending logs', async () => {
154+
vi.useFakeTimers()
155+
156+
const { fetchMock, originalWarnMock, restore } = setupWarnConsolePipe()
157+
158+
try {
159+
const circular: Record<string, unknown> = { name: 'root' }
160+
circular.self = circular
161+
const error = new Error('boom')
162+
163+
console.warn('complex warning', circular, error)
164+
165+
await vi.advanceTimersByTimeAsync(100)
166+
167+
expect(originalWarnMock).toHaveBeenCalledWith(
168+
'complex warning',
169+
circular,
170+
error,
171+
)
172+
expect(fetchMock).toHaveBeenCalledTimes(1)
173+
174+
const body = getFirstFetchBody(fetchMock)
175+
176+
expect(body.entries[0].args[1]).toEqual({
177+
name: 'root',
178+
self: '[Circular]',
179+
})
180+
expect(body.entries[0].args[2]).toMatchObject({
181+
name: 'Error',
182+
message: 'boom',
183+
})
184+
expect(typeof body.entries[0].args[2].stack).toBe('string')
185+
} finally {
186+
restore()
187+
}
188+
})
189+
190+
test('limits large console payloads before sending logs', async () => {
191+
vi.useFakeTimers()
192+
193+
const { fetchMock, restore } = setupWarnConsolePipe()
194+
195+
try {
196+
const longArray = Array.from({ length: 101 }, (_, index) => index)
197+
const manyKeys: Record<string, number> = {}
198+
for (let index = 0; index < 101; index++) {
199+
manyKeys['key' + index] = index
200+
}
201+
const longString = 'x'.repeat(10001)
202+
const typedArray = new Uint8Array(1024)
203+
const deepObject = {
204+
a: {
205+
b: {
206+
c: {
207+
d: {
208+
e: {
209+
f: {
210+
g: 'too deep',
211+
},
212+
},
213+
},
214+
},
215+
},
216+
},
217+
}
218+
219+
console.warn(
220+
'large payload',
221+
longArray,
222+
manyKeys,
223+
longString,
224+
typedArray,
225+
deepObject,
226+
)
227+
228+
await vi.advanceTimersByTimeAsync(100)
229+
230+
const body = getFirstFetchBody(fetchMock)
231+
232+
expect(body.entries[0].args[1]).toHaveLength(101)
233+
expect(body.entries[0].args[1][100]).toBe('... (1 more)')
234+
expect(Object.keys(body.entries[0].args[2])).toHaveLength(101)
235+
expect(body.entries[0].args[2]['...']).toBe('(1 more keys)')
236+
expect(body.entries[0].args[3].startsWith('x'.repeat(10000))).toBe(true)
237+
expect(body.entries[0].args[3]).toContain('... (1 more chars)')
238+
expect(body.entries[0].args[4]).toBe('[Uint8Array(1024)]')
239+
expect(body.entries[0].args[5].a.b.c.d.e.f).toBe('[MaxDepth]')
240+
} finally {
241+
restore()
242+
}
243+
})
73244
})

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

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,151 @@ export function generateConsolePipeCode(
8989
}
9090
}
9191
92+
var MAX_DEPTH = 6;
93+
var MAX_ARRAY_LEN = 100;
94+
var MAX_OBJECT_KEYS = 100;
95+
var MAX_STRING_LENGTH = 10000;
96+
97+
function truncateString(value) {
98+
if (value.length <= MAX_STRING_LENGTH) return value;
99+
return value.slice(0, MAX_STRING_LENGTH) + '... (' + (value.length - MAX_STRING_LENGTH) + ' more chars)';
100+
}
101+
102+
function formatDomElement(element) {
103+
var tagName = element.tagName ? element.tagName.toLowerCase() : 'element';
104+
var output = '<' + tagName;
105+
var attrs = ['id', 'class', 'name', 'type', 'role', 'aria-label', 'data-testid'];
106+
107+
for (var attrIndex = 0; attrIndex < attrs.length; attrIndex++) {
108+
var attrName = attrs[attrIndex];
109+
var attrValue = element.getAttribute && element.getAttribute(attrName);
110+
if (attrValue) {
111+
output += ' ' + attrName + '="' + truncateString(String(attrValue)).replace(/"/g, '&quot;') + '"';
112+
}
113+
}
114+
115+
return output + '>';
116+
}
117+
118+
function getObjectTypeName(arg) {
119+
return arg && arg.constructor && arg.constructor.name ? arg.constructor.name : 'Object';
120+
}
121+
122+
function formatArrayBufferView(arg) {
123+
var length = typeof arg.length === 'number' ? arg.length : arg.byteLength;
124+
return '[' + getObjectTypeName(arg) + '(' + length + ')]';
125+
}
126+
127+
function serializeConsoleArg(arg, seen, depth) {
128+
if (depth === undefined) depth = 0;
129+
if (arg === undefined) return 'undefined';
130+
if (arg === null) return null;
131+
132+
var argType = typeof arg;
133+
134+
if (argType === 'function') return '[Function' + (arg.name ? ': ' + arg.name : '') + ']';
135+
if (argType === 'symbol') return arg.toString();
136+
if (argType === 'bigint') return arg.toString() + 'n';
137+
if (argType === 'string') return truncateString(arg);
138+
if (argType !== 'object') return arg;
139+
140+
if (!isServer) {
141+
if (
142+
arg.nodeType === 1 &&
143+
typeof arg.tagName === 'string' &&
144+
typeof arg.getAttribute === 'function'
145+
) {
146+
return formatDomElement(arg);
147+
}
148+
if (arg.nodeType === 9) {
149+
return '[Document]';
150+
}
151+
if (typeof Window !== 'undefined' && arg instanceof Window) {
152+
return '[Window]';
153+
}
154+
if (typeof Event !== 'undefined' && arg instanceof Event) {
155+
return {
156+
type: arg.type,
157+
target: serializeConsoleArg(arg.target, seen, depth + 1),
158+
currentTarget: serializeConsoleArg(arg.currentTarget, seen, depth + 1),
159+
defaultPrevented: arg.defaultPrevented,
160+
};
161+
}
162+
if (typeof Node !== 'undefined' && arg instanceof Node) {
163+
return '[Node: ' + arg.nodeName + ']';
164+
}
165+
}
166+
167+
if (arg instanceof Error) {
168+
return {
169+
name: arg.name,
170+
message: truncateString(arg.message),
171+
stack: arg.stack ? truncateString(arg.stack) : arg.stack,
172+
};
173+
}
174+
175+
if (arg instanceof Date) {
176+
return arg.toISOString();
177+
}
178+
179+
if (arg instanceof RegExp) {
180+
return arg.toString();
181+
}
182+
183+
if (typeof ArrayBuffer !== 'undefined') {
184+
if (ArrayBuffer.isView && ArrayBuffer.isView(arg)) {
185+
return formatArrayBufferView(arg);
186+
}
187+
if (arg instanceof ArrayBuffer) {
188+
return '[ArrayBuffer(' + arg.byteLength + ')]';
189+
}
190+
}
191+
192+
if (typeof SharedArrayBuffer !== 'undefined' && arg instanceof SharedArrayBuffer) {
193+
return '[SharedArrayBuffer(' + arg.byteLength + ')]';
194+
}
195+
196+
if (depth >= MAX_DEPTH) {
197+
return '[MaxDepth]';
198+
}
199+
200+
if (seen.indexOf(arg) !== -1) {
201+
return '[Circular]';
202+
}
203+
204+
seen.push(arg);
205+
206+
if (Array.isArray(arg)) {
207+
var arrayResult = [];
208+
var arrayLength = Math.min(arg.length, MAX_ARRAY_LEN);
209+
for (var arrayIndex = 0; arrayIndex < arrayLength; arrayIndex++) {
210+
arrayResult.push(serializeConsoleArg(arg[arrayIndex], seen, depth + 1));
211+
}
212+
if (arg.length > MAX_ARRAY_LEN) {
213+
arrayResult.push('... (' + (arg.length - MAX_ARRAY_LEN) + ' more)');
214+
}
215+
seen.pop();
216+
return arrayResult;
217+
}
218+
219+
var objectResult = {};
220+
var keys = Object.keys(arg);
221+
var keyLength = Math.min(keys.length, MAX_OBJECT_KEYS);
222+
for (var keyIndex = 0; keyIndex < keyLength; keyIndex++) {
223+
var key = keys[keyIndex];
224+
try {
225+
objectResult[key] = serializeConsoleArg(arg[key], seen, depth + 1);
226+
} catch (error) {
227+
objectResult[key] = '[Thrown: ' + String(error) + ']';
228+
}
229+
}
230+
if (keys.length > MAX_OBJECT_KEYS) {
231+
objectResult['...'] = '(' + (keys.length - MAX_OBJECT_KEYS) + ' more keys)';
232+
}
233+
seen.pop();
234+
return objectResult;
235+
}
236+
92237
// Override global console methods
93238
for (var j = 0; j < CONSOLE_LEVELS.length; j++) {
94239
(function(level) {
@@ -106,15 +251,9 @@ export function generateConsolePipeCode(
106251
return;
107252
}
108253
109-
// Serialize args safely
110254
var safeArgs = args.map(function(arg) {
111-
if (arg === undefined) return 'undefined';
112-
if (arg === null) return null;
113-
if (typeof arg === 'function') return '[Function]';
114-
if (typeof arg === 'symbol') return arg.toString();
115255
try {
116-
JSON.stringify(arg);
117-
return arg;
256+
return serializeConsoleArg(arg, [], 0);
118257
} catch (e) {
119258
return String(arg);
120259
}

0 commit comments

Comments
 (0)