Skip to content
Merged
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
39 changes: 37 additions & 2 deletions packages/vite/src/shared/__tests__/forwardConsole.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('formatConsoleArgs', () => {
'done',
]),
).toMatchInlineSnapshot(
`"format: string=hello number=12.9 int=42 float=3.5 json={"id":1} object={"enabled":true} object2={"nested":{"deep":1}} sym=NaN style= literal=% trailing done"`,
`"format: string=hello number=12.9 int=42 float=3.5 json={"id":1} object={ enabled: true } object2={ nested: { deep: 1 } } sym=NaN style= literal=% trailing done"`,
)

expect(
Expand Down Expand Up @@ -63,9 +63,23 @@ describe('formatConsoleArgs', () => {
circular,
]),
).toMatchInlineSnapshot(
`"1n undefined true Symbol(s) [Function: sampleFn] Error: boom {"ok":true,"big":"2n","err":{"name":"Error","message":"nested"},"self":"[Circular]"}"`,
`"1n undefined true Symbol(s) [Function: sampleFn] Error: boom { ok: true, big: 2n, err: [Error: nested], self: [Circular] }"`,
)
})

test('limits object depth and width', () => {
const deep = { a: { b: { c: { d: true } } } }
expect(formatConsoleArgs([deep])).toMatchInlineSnapshot(
`"{ a: { b: { c: [Object] } } }"`,
)

const wide = Object.fromEntries(
Array.from({ length: 101 }, (_, i) => [`k${i}`, i]),
)
const output = formatConsoleArgs([wide])
expect(output).toMatch(/^\{ k0: 0, k1: 1, k2: 2, /)
expect(output).toMatch(/, k98: 98, k99: 99, …\(1\) \}$/)
})
})

describe('setupForwardConsoleHandler', () => {
Expand Down Expand Up @@ -136,4 +150,25 @@ describe('setupForwardConsoleHandler', () => {
new Error('other error'),
)
})

test('caps the forwarded console message', () => {
const send = vi.fn(() => Promise.resolve())
const transport = createMockTransport(send)
const console = createMockConsole()

setupForwardConsoleHandler(
transport,
{
enabled: true,
unhandledErrors: false,
logLevels: ['log'],
},
console,
)

console.log('x'.repeat(20_000))

const message = send.mock.calls[0][0].data.data.message
expect(message).toBe(`${'x'.repeat(9_999)}…`)
})
})
27 changes: 27 additions & 0 deletions packages/vite/src/shared/__tests__/pretty-format.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest'
import { prettyFormat } from '../pretty-format'

describe('prettyFormat', () => {
test('formats basic JavaScript values', () => {
const circular: Record<string, unknown> = {}
circular.self = circular

expect(
prettyFormat({
array: [1, 'two'],
date: new Date('2020-01-01T00:00:00.000Z'),
error: new Error('boom'),
circular,
}),
).toMatchInlineSnapshot(
`"{ array: [ 1, "two" ], date: 2020-01-01T00:00:00.000Z, error: [Error: boom], circular: { self: [Circular] } }"`,
)
})

test('limits depth and collection width', () => {
expect(prettyFormat({ a: { b: { c: { d: true } } } })).toBe(
'{ a: { b: { c: [Object] } } }',
)
expect(prettyFormat([1, 2, 3, 4], { maxWidth: 2 })).toBe('[ 1, 2, …(2) ]')
})
})
59 changes: 21 additions & 38 deletions packages/vite/src/shared/forwardConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type NormalizedModuleRunnerTransport,
SendBeforeConnectError,
} from './moduleRunnerTransport'
import { prettyFormat } from './pretty-format'

export type ForwardConsoleLogLevel =
| 'error'
Expand Down Expand Up @@ -56,7 +57,7 @@ export function setupForwardConsoleHandler(
type: 'log',
data: {
level,
message: formatConsoleArgs(args),
message: truncateConsoleMessage(formatConsoleArgs(args)),
},
} satisfies ForwardConsolePayload,
})
Expand Down Expand Up @@ -190,49 +191,31 @@ function stringifyConsoleArg(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'undefined'
) {
return String(value)
}
if (typeof value === 'symbol') {
return value.toString()
}
if (typeof value === 'function') {
return value.name ? `[Function: ${value.name}]` : '[Function]'
}
if (value instanceof Error) {
return value.stack || `${value.name}: ${value.message}`
}
if (typeof value === 'bigint') {
return `${value}n`
}

const seen = new WeakSet<object>()
try {
const serialized = JSON.stringify(value, (_, nested) => {
if (typeof nested === 'bigint') {
return `${nested}n`
}
if (nested instanceof Error) {
return {
name: nested.name,
message: nested.message,
stack: nested.stack,
}
}
if (nested && typeof nested === 'object') {
if (seen.has(nested)) {
return '[Circular]'
}
seen.add(nested)
}
return nested
})
return serialized ?? String(value)
return prettyFormat(value)
} catch {
return String(value)
}
}

// hard-truncate the generated string in addition to pretty-format-level structural truncation
const MAX_CONSOLE_MESSAGE_LENGTH = 10_000

function truncateConsoleMessage(message: string): string {
if (message.length <= MAX_CONSOLE_MESSAGE_LENGTH) {
return message
}
let end = MAX_CONSOLE_MESSAGE_LENGTH - 1
if (isHighSurrogate(message[end - 1])) {
end--
}
return `${message.slice(0, end)}…`
}

function isHighSurrogate(value: string): boolean {
return value >= '\uD800' && value <= '\uDBFF'
}
157 changes: 157 additions & 0 deletions packages/vite/src/shared/pretty-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Minimal subset of @vitest/pretty-format for forwarded console objects.
*
* This keeps its basic/complex printer split, path-based circular reference
* tracking, and bounded array/object traversal. It omits plugins, colors,
* indentation, specialized collection printers, toJSON calls, output-budget
* heuristics, and option validation.
*
* At introduction, this formatter and its forward-console integration added
* about 2.7 kB raw / 1.0 kB gzip to Vite's client bundle, compared with an
* estimated 27.6 kB raw / 7.0 kB gzip for a tree-shaken direct dependency on
* @vitest/pretty-format.
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors.
* MIT License.
*/

interface Config {
maxDepth: number
maxWidth: number
}

export type PrettyFormatOptions = Partial<Config>

type Refs = unknown[]

const DEFAULT_OPTIONS: Config = {
// Node's default inspect depth is 2 levels below the root. This formatter
// counts the root as the first level, so 3 produces equivalent nesting.
maxDepth: 3,
// Match Node's default maxArrayLength, and also apply it to object properties
// so both deep and wide object graphs remain bounded.
maxWidth: 100,
}

const objectToString = Object.prototype.toString
const errorToString = Error.prototype.toString
const identifierRE = /^[a-z_$][\w$]*$/i

export function prettyFormat(
value: unknown,
options: PrettyFormatOptions = {},
): string {
const config: Config = { ...DEFAULT_OPTIONS, ...options }
return printer(value, config, 0, [])
}

function printer(
value: unknown,
config: Config,
depth: number,
refs: Refs,
): string {
const basic = printBasicValue(value)
return basic ?? printComplexValue(value as object, config, depth, refs)
}

function printBasicValue(value: unknown): string | undefined {
if (value == null) {
return String(value)
}
if (typeof value === 'string') {
return JSON.stringify(value)
}
if (typeof value === 'number') {
return Object.is(value, -0) ? '-0' : String(value)
}
if (typeof value === 'bigint') {
return `${value}n`
}
if (typeof value === 'boolean' || typeof value === 'symbol') {
return String(value)
}
if (typeof value === 'function') {
return value.name ? `[Function: ${value.name}]` : '[Function]'
}

const type = objectToString.call(value)
if (type === '[object Date]') {
return Number.isNaN(+(value as Date))
? 'Date { NaN }'
: (value as Date).toISOString()
}
if (type === '[object Error]' || value instanceof Error) {
return `[${errorToString.call(value)}]`
}
}

function printComplexValue(
value: object,
config: Config,
depth: number,
refs: Refs,
): string {
if (refs.includes(value)) {
return '[Circular]'
}
refs = [...refs, value]

const hitMaxDepth = ++depth > config.maxDepth
if (Array.isArray(value)) {
return hitMaxDepth
? '[Array]'
: `[${printListItems(value, config, depth, refs)}]`
}
return hitMaxDepth
? '[Object]'
: `{${printObjectProperties(
value as Record<string, unknown>,
config,
depth,
refs,
)}}`
}

function printListItems(
value: unknown[],
config: Config,
depth: number,
refs: Refs,
): string {
const width = Math.min(value.length, config.maxWidth)
const result: string[] = []
for (let i = 0; i < width; i++) {
result.push(i in value ? printer(value[i], config, depth, refs) : '')
}
if (width < value.length) {
result.push(`…(${value.length - width})`)
}
return withSpacing(result)
}

function printObjectProperties(
value: Record<string, unknown>,
config: Config,
depth: number,
refs: Refs,
): string {
const keys = Object.keys(value)
const width = Math.min(keys.length, config.maxWidth)
const result: string[] = []
for (let i = 0; i < width; i++) {
const key = keys[i]
const name =
key !== '__proto__' && identifierRE.test(key) ? key : JSON.stringify(key)
result.push(`${name}: ${printer(value[key], config, depth, refs)}`)
}
if (width < keys.length) {
result.push(`…(${keys.length - width})`)
}
return withSpacing(result)
}

function withSpacing(values: string[]): string {
return values.length === 0 ? '' : ` ${values.join(', ')} `
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ test.runIf(isServe)('console.error', async () => {
await expect
.poll(() => normalizeLogs(serverLogs.slice(logIndex)))
.toContain(
`[console.error] format: string=hello number=12.9 int=42 float=3.5 json={"id":1} object={"enabled":true} object2={"nested":{"deep":1}} style= literal=% trailing done`,
`[console.error] format: string=hello number=12.9 int=42 float=3.5 json={"id":1} object={ enabled: true } object2={ nested: { deep: 1 } } style= literal=% trailing done`,
)
})

Expand Down
Loading