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/safe-console-dom-pipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/devtools-vite': patch
---

Avoid piping raw DOM nodes through the console proxy.
173 changes: 172 additions & 1 deletion packages/devtools-vite/src/virtual-console.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,51 @@
import { describe, expect, test } from 'vitest'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { generateConsolePipeCode } from './virtual-console'

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

afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
delete (window as any).__TSD_CONSOLE_PIPE_INITIALIZED__
})

function setupWarnConsolePipe() {
const originalWarn = console.warn
const originalWarnMock = vi.fn()
const fetchMock = vi.fn().mockResolvedValue(undefined)
const eventSourceUrls: Array<string> = []

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

constructor(url: string) {
eventSourceUrls.push(url)
}
}

console.warn = originalWarnMock
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('EventSource', MockEventSource)

const code = generateConsolePipeCode(['warn'], TEST_VITE_URL)
new Function(code)()

return {
eventSourceUrls,
fetchMock,
originalWarnMock,
restore: () => {
console.warn = originalWarn
},
}
}

function getFirstFetchBody(fetchMock: ReturnType<typeof vi.fn>) {
const [, init] = fetchMock.mock.calls[0]!
return JSON.parse(init.body)
}

describe('virtual-console', () => {
test('generates inline code with specified levels', () => {
const code = generateConsolePipeCode(['log', 'error'], TEST_VITE_URL)
Expand Down Expand Up @@ -70,4 +113,132 @@ describe('virtual-console', () => {
expect(code).toContain(TEST_VITE_URL)
expect(code).toContain('/__tsd/console-pipe/server')
})

test('serializes DOM elements before sending logs', async () => {
vi.useFakeTimers()

const { eventSourceUrls, fetchMock, originalWarnMock, restore } =
setupWarnConsolePipe()

try {
const button = document.createElement('button')
button.id = 'save'
button.className = 'primary'
button.type = 'button'
button.setAttribute('data-testid', 'save-button')

console.warn('gesture warning', button)

await vi.advanceTimersByTimeAsync(100)

expect(originalWarnMock).toHaveBeenCalledWith('gesture warning', button)
expect(eventSourceUrls).toEqual(['/__tsd/console-pipe/sse'])
expect(fetchMock).toHaveBeenCalledTimes(1)

const body = getFirstFetchBody(fetchMock)

expect(body.entries[0]).toMatchObject({
level: 'warn',
source: 'client',
args: [
'gesture warning',
'<button id="save" class="primary" type="button" data-testid="save-button">',
],
})
} finally {
restore()
}
})

test('serializes circular refs and Error objects before sending logs', async () => {
vi.useFakeTimers()

const { fetchMock, originalWarnMock, restore } = setupWarnConsolePipe()

try {
const circular: Record<string, unknown> = { name: 'root' }
circular.self = circular
const error = new Error('boom')

console.warn('complex warning', circular, error)

await vi.advanceTimersByTimeAsync(100)

expect(originalWarnMock).toHaveBeenCalledWith(
'complex warning',
circular,
error,
)
expect(fetchMock).toHaveBeenCalledTimes(1)

const body = getFirstFetchBody(fetchMock)

expect(body.entries[0].args[1]).toEqual({
name: 'root',
self: '[Circular]',
})
expect(body.entries[0].args[2]).toMatchObject({
name: 'Error',
message: 'boom',
})
expect(typeof body.entries[0].args[2].stack).toBe('string')
} finally {
restore()
}
})

test('limits large console payloads before sending logs', async () => {
vi.useFakeTimers()

const { fetchMock, restore } = setupWarnConsolePipe()

try {
const longArray = Array.from({ length: 101 }, (_, index) => index)
const manyKeys: Record<string, number> = {}
for (let index = 0; index < 101; index++) {
manyKeys['key' + index] = index
}
const longString = 'x'.repeat(10001)
const typedArray = new Uint8Array(1024)
const deepObject = {
a: {
b: {
c: {
d: {
e: {
f: {
g: 'too deep',
},
},
},
},
},
},
}

console.warn(
'large payload',
longArray,
manyKeys,
longString,
typedArray,
deepObject,
)

await vi.advanceTimersByTimeAsync(100)

const body = getFirstFetchBody(fetchMock)

expect(body.entries[0].args[1]).toHaveLength(101)
expect(body.entries[0].args[1][100]).toBe('... (1 more)')
expect(Object.keys(body.entries[0].args[2])).toHaveLength(101)
expect(body.entries[0].args[2]['...']).toBe('(1 more keys)')
expect(body.entries[0].args[3].startsWith('x'.repeat(10000))).toBe(true)
expect(body.entries[0].args[3]).toContain('... (1 more chars)')
expect(body.entries[0].args[4]).toBe('[Uint8Array(1024)]')
expect(body.entries[0].args[5].a.b.c.d.e.f).toBe('[MaxDepth]')
} finally {
restore()
}
})
})
153 changes: 146 additions & 7 deletions packages/devtools-vite/src/virtual-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,151 @@ export function generateConsolePipeCode(
}
}

var MAX_DEPTH = 6;
var MAX_ARRAY_LEN = 100;
var MAX_OBJECT_KEYS = 100;
var MAX_STRING_LENGTH = 10000;

function truncateString(value) {
if (value.length <= MAX_STRING_LENGTH) return value;
return value.slice(0, MAX_STRING_LENGTH) + '... (' + (value.length - MAX_STRING_LENGTH) + ' more chars)';
}

function formatDomElement(element) {
var tagName = element.tagName ? element.tagName.toLowerCase() : 'element';
var output = '<' + tagName;
var attrs = ['id', 'class', 'name', 'type', 'role', 'aria-label', 'data-testid'];

for (var attrIndex = 0; attrIndex < attrs.length; attrIndex++) {
var attrName = attrs[attrIndex];
var attrValue = element.getAttribute && element.getAttribute(attrName);
if (attrValue) {
output += ' ' + attrName + '="' + truncateString(String(attrValue)).replace(/"/g, '&quot;') + '"';
}
}

return output + '>';
}

function getObjectTypeName(arg) {
return arg && arg.constructor && arg.constructor.name ? arg.constructor.name : 'Object';
}

function formatArrayBufferView(arg) {
var length = typeof arg.length === 'number' ? arg.length : arg.byteLength;
return '[' + getObjectTypeName(arg) + '(' + length + ')]';
}

function serializeConsoleArg(arg, seen, depth) {
if (depth === undefined) depth = 0;
if (arg === undefined) return 'undefined';
if (arg === null) return null;

var argType = typeof arg;

if (argType === 'function') return '[Function' + (arg.name ? ': ' + arg.name : '') + ']';
if (argType === 'symbol') return arg.toString();
if (argType === 'bigint') return arg.toString() + 'n';
if (argType === 'string') return truncateString(arg);
if (argType !== 'object') return arg;

if (!isServer) {
if (
arg.nodeType === 1 &&
typeof arg.tagName === 'string' &&
typeof arg.getAttribute === 'function'
) {
return formatDomElement(arg);
}
if (arg.nodeType === 9) {
return '[Document]';
}
if (typeof Window !== 'undefined' && arg instanceof Window) {
return '[Window]';
}
if (typeof Event !== 'undefined' && arg instanceof Event) {
return {
type: arg.type,
target: serializeConsoleArg(arg.target, seen, depth + 1),
currentTarget: serializeConsoleArg(arg.currentTarget, seen, depth + 1),
defaultPrevented: arg.defaultPrevented,
};
}
if (typeof Node !== 'undefined' && arg instanceof Node) {
return '[Node: ' + arg.nodeName + ']';
}
}

if (arg instanceof Error) {
return {
name: arg.name,
message: truncateString(arg.message),
stack: arg.stack ? truncateString(arg.stack) : arg.stack,
};
}

if (arg instanceof Date) {
return arg.toISOString();
}

if (arg instanceof RegExp) {
return arg.toString();
}

if (typeof ArrayBuffer !== 'undefined') {
if (ArrayBuffer.isView && ArrayBuffer.isView(arg)) {
return formatArrayBufferView(arg);
}
if (arg instanceof ArrayBuffer) {
return '[ArrayBuffer(' + arg.byteLength + ')]';
}
}

if (typeof SharedArrayBuffer !== 'undefined' && arg instanceof SharedArrayBuffer) {
return '[SharedArrayBuffer(' + arg.byteLength + ')]';
}

if (depth >= MAX_DEPTH) {
return '[MaxDepth]';
}

if (seen.indexOf(arg) !== -1) {
return '[Circular]';
}

seen.push(arg);

if (Array.isArray(arg)) {
var arrayResult = [];
var arrayLength = Math.min(arg.length, MAX_ARRAY_LEN);
for (var arrayIndex = 0; arrayIndex < arrayLength; arrayIndex++) {
arrayResult.push(serializeConsoleArg(arg[arrayIndex], seen, depth + 1));
}
if (arg.length > MAX_ARRAY_LEN) {
arrayResult.push('... (' + (arg.length - MAX_ARRAY_LEN) + ' more)');
}
seen.pop();
return arrayResult;
}

var objectResult = {};
var keys = Object.keys(arg);
var keyLength = Math.min(keys.length, MAX_OBJECT_KEYS);
for (var keyIndex = 0; keyIndex < keyLength; keyIndex++) {
var key = keys[keyIndex];
try {
objectResult[key] = serializeConsoleArg(arg[key], seen, depth + 1);
} catch (error) {
objectResult[key] = '[Thrown: ' + String(error) + ']';
}
}
if (keys.length > MAX_OBJECT_KEYS) {
objectResult['...'] = '(' + (keys.length - MAX_OBJECT_KEYS) + ' more keys)';
}
seen.pop();
return objectResult;
}

// Override global console methods
for (var j = 0; j < CONSOLE_LEVELS.length; j++) {
(function(level) {
Expand All @@ -106,15 +251,9 @@ export function generateConsolePipeCode(
return;
}

// Serialize args safely
var safeArgs = args.map(function(arg) {
if (arg === undefined) return 'undefined';
if (arg === null) return null;
if (typeof arg === 'function') return '[Function]';
if (typeof arg === 'symbol') return arg.toString();
try {
JSON.stringify(arg);
return arg;
return serializeConsoleArg(arg, [], 0);
} catch (e) {
return String(arg);
}
Expand Down