Skip to content

Commit e64a587

Browse files
hi-ogawacodex
andauthored
fix: limit size of object and array printing via forwardConsole (#23565)
Co-authored-by: Codex (GPT-5) <noreply@openai.com>
1 parent 39ddf7c commit e64a587

5 files changed

Lines changed: 243 additions & 41 deletions

File tree

‎packages/vite/src/shared/__tests__/forwardConsole.spec.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('formatConsoleArgs', () => {
2626
'done',
2727
]),
2828
).toMatchInlineSnapshot(
29-
`"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"`,
29+
`"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"`,
3030
)
3131

3232
expect(
@@ -63,9 +63,23 @@ describe('formatConsoleArgs', () => {
6363
circular,
6464
]),
6565
).toMatchInlineSnapshot(
66-
`"1n undefined true Symbol(s) [Function: sampleFn] Error: boom {"ok":true,"big":"2n","err":{"name":"Error","message":"nested"},"self":"[Circular]"}"`,
66+
`"1n undefined true Symbol(s) [Function: sampleFn] Error: boom { ok: true, big: 2n, err: [Error: nested], self: [Circular] }"`,
6767
)
6868
})
69+
70+
test('limits object depth and width', () => {
71+
const deep = { a: { b: { c: { d: true } } } }
72+
expect(formatConsoleArgs([deep])).toMatchInlineSnapshot(
73+
`"{ a: { b: { c: [Object] } } }"`,
74+
)
75+
76+
const wide = Object.fromEntries(
77+
Array.from({ length: 101 }, (_, i) => [`k${i}`, i]),
78+
)
79+
const output = formatConsoleArgs([wide])
80+
expect(output).toMatch(/^\{ k0: 0, k1: 1, k2: 2, /)
81+
expect(output).toMatch(/, k98: 98, k99: 99, …\(1\) \}$/)
82+
})
6983
})
7084

7185
describe('setupForwardConsoleHandler', () => {
@@ -136,4 +150,25 @@ describe('setupForwardConsoleHandler', () => {
136150
new Error('other error'),
137151
)
138152
})
153+
154+
test('caps the forwarded console message', () => {
155+
const send = vi.fn(() => Promise.resolve())
156+
const transport = createMockTransport(send)
157+
const console = createMockConsole()
158+
159+
setupForwardConsoleHandler(
160+
transport,
161+
{
162+
enabled: true,
163+
unhandledErrors: false,
164+
logLevels: ['log'],
165+
},
166+
console,
167+
)
168+
169+
console.log('x'.repeat(20_000))
170+
171+
const message = send.mock.calls[0][0].data.data.message
172+
expect(message).toBe(`${'x'.repeat(9_999)}…`)
173+
})
139174
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { prettyFormat } from '../pretty-format'
3+
4+
describe('prettyFormat', () => {
5+
test('formats basic JavaScript values', () => {
6+
const circular: Record<string, unknown> = {}
7+
circular.self = circular
8+
9+
expect(
10+
prettyFormat({
11+
array: [1, 'two'],
12+
date: new Date('2020-01-01T00:00:00.000Z'),
13+
error: new Error('boom'),
14+
circular,
15+
}),
16+
).toMatchInlineSnapshot(
17+
`"{ array: [ 1, "two" ], date: 2020-01-01T00:00:00.000Z, error: [Error: boom], circular: { self: [Circular] } }"`,
18+
)
19+
})
20+
21+
test('limits depth and collection width', () => {
22+
expect(prettyFormat({ a: { b: { c: { d: true } } } })).toBe(
23+
'{ a: { b: { c: [Object] } } }',
24+
)
25+
expect(prettyFormat([1, 2, 3, 4], { maxWidth: 2 })).toBe('[ 1, 2, …(2) ]')
26+
})
27+
})

‎packages/vite/src/shared/forwardConsole.ts‎

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type NormalizedModuleRunnerTransport,
44
SendBeforeConnectError,
55
} from './moduleRunnerTransport'
6+
import { prettyFormat } from './pretty-format'
67

78
export type ForwardConsoleLogLevel =
89
| 'error'
@@ -56,7 +57,7 @@ export function setupForwardConsoleHandler(
5657
type: 'log',
5758
data: {
5859
level,
59-
message: formatConsoleArgs(args),
60+
message: truncateConsoleMessage(formatConsoleArgs(args)),
6061
},
6162
} satisfies ForwardConsolePayload,
6263
})
@@ -190,49 +191,31 @@ function stringifyConsoleArg(value: unknown): string {
190191
if (typeof value === 'string') {
191192
return value
192193
}
193-
if (
194-
typeof value === 'number' ||
195-
typeof value === 'boolean' ||
196-
typeof value === 'undefined'
197-
) {
198-
return String(value)
199-
}
200-
if (typeof value === 'symbol') {
201-
return value.toString()
202-
}
203-
if (typeof value === 'function') {
204-
return value.name ? `[Function: ${value.name}]` : '[Function]'
205-
}
206194
if (value instanceof Error) {
207195
return value.stack || `${value.name}: ${value.message}`
208196
}
209-
if (typeof value === 'bigint') {
210-
return `${value}n`
211-
}
212197

213-
const seen = new WeakSet<object>()
214198
try {
215-
const serialized = JSON.stringify(value, (_, nested) => {
216-
if (typeof nested === 'bigint') {
217-
return `${nested}n`
218-
}
219-
if (nested instanceof Error) {
220-
return {
221-
name: nested.name,
222-
message: nested.message,
223-
stack: nested.stack,
224-
}
225-
}
226-
if (nested && typeof nested === 'object') {
227-
if (seen.has(nested)) {
228-
return '[Circular]'
229-
}
230-
seen.add(nested)
231-
}
232-
return nested
233-
})
234-
return serialized ?? String(value)
199+
return prettyFormat(value)
235200
} catch {
236201
return String(value)
237202
}
238203
}
204+
205+
// hard-truncate the generated string in addition to pretty-format-level structural truncation
206+
const MAX_CONSOLE_MESSAGE_LENGTH = 10_000
207+
208+
function truncateConsoleMessage(message: string): string {
209+
if (message.length <= MAX_CONSOLE_MESSAGE_LENGTH) {
210+
return message
211+
}
212+
let end = MAX_CONSOLE_MESSAGE_LENGTH - 1
213+
if (isHighSurrogate(message[end - 1])) {
214+
end--
215+
}
216+
return `${message.slice(0, end)}…`
217+
}
218+
219+
function isHighSurrogate(value: string): boolean {
220+
return value >= '\uD800' && value <= '\uDBFF'
221+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Minimal subset of @vitest/pretty-format for forwarded console objects.
3+
*
4+
* This keeps its basic/complex printer split, path-based circular reference
5+
* tracking, and bounded array/object traversal. It omits plugins, colors,
6+
* indentation, specialized collection printers, toJSON calls, output-budget
7+
* heuristics, and option validation.
8+
*
9+
* At introduction, this formatter and its forward-console integration added
10+
* about 2.7 kB raw / 1.0 kB gzip to Vite's client bundle, compared with an
11+
* estimated 27.6 kB raw / 7.0 kB gzip for a tree-shaken direct dependency on
12+
* @vitest/pretty-format.
13+
*
14+
* Copyright (c) Meta Platforms, Inc. and affiliates.
15+
* Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors.
16+
* MIT License.
17+
*/
18+
19+
interface Config {
20+
maxDepth: number
21+
maxWidth: number
22+
}
23+
24+
export type PrettyFormatOptions = Partial<Config>
25+
26+
type Refs = unknown[]
27+
28+
const DEFAULT_OPTIONS: Config = {
29+
// Node's default inspect depth is 2 levels below the root. This formatter
30+
// counts the root as the first level, so 3 produces equivalent nesting.
31+
maxDepth: 3,
32+
// Match Node's default maxArrayLength, and also apply it to object properties
33+
// so both deep and wide object graphs remain bounded.
34+
maxWidth: 100,
35+
}
36+
37+
const objectToString = Object.prototype.toString
38+
const errorToString = Error.prototype.toString
39+
const identifierRE = /^[a-z_$][\w$]*$/i
40+
41+
export function prettyFormat(
42+
value: unknown,
43+
options: PrettyFormatOptions = {},
44+
): string {
45+
const config: Config = { ...DEFAULT_OPTIONS, ...options }
46+
return printer(value, config, 0, [])
47+
}
48+
49+
function printer(
50+
value: unknown,
51+
config: Config,
52+
depth: number,
53+
refs: Refs,
54+
): string {
55+
const basic = printBasicValue(value)
56+
return basic ?? printComplexValue(value as object, config, depth, refs)
57+
}
58+
59+
function printBasicValue(value: unknown): string | undefined {
60+
if (value == null) {
61+
return String(value)
62+
}
63+
if (typeof value === 'string') {
64+
return JSON.stringify(value)
65+
}
66+
if (typeof value === 'number') {
67+
return Object.is(value, -0) ? '-0' : String(value)
68+
}
69+
if (typeof value === 'bigint') {
70+
return `${value}n`
71+
}
72+
if (typeof value === 'boolean' || typeof value === 'symbol') {
73+
return String(value)
74+
}
75+
if (typeof value === 'function') {
76+
return value.name ? `[Function: ${value.name}]` : '[Function]'
77+
}
78+
79+
const type = objectToString.call(value)
80+
if (type === '[object Date]') {
81+
return Number.isNaN(+(value as Date))
82+
? 'Date { NaN }'
83+
: (value as Date).toISOString()
84+
}
85+
if (type === '[object Error]' || value instanceof Error) {
86+
return `[${errorToString.call(value)}]`
87+
}
88+
}
89+
90+
function printComplexValue(
91+
value: object,
92+
config: Config,
93+
depth: number,
94+
refs: Refs,
95+
): string {
96+
if (refs.includes(value)) {
97+
return '[Circular]'
98+
}
99+
refs = [...refs, value]
100+
101+
const hitMaxDepth = ++depth > config.maxDepth
102+
if (Array.isArray(value)) {
103+
return hitMaxDepth
104+
? '[Array]'
105+
: `[${printListItems(value, config, depth, refs)}]`
106+
}
107+
return hitMaxDepth
108+
? '[Object]'
109+
: `{${printObjectProperties(
110+
value as Record<string, unknown>,
111+
config,
112+
depth,
113+
refs,
114+
)}}`
115+
}
116+
117+
function printListItems(
118+
value: unknown[],
119+
config: Config,
120+
depth: number,
121+
refs: Refs,
122+
): string {
123+
const width = Math.min(value.length, config.maxWidth)
124+
const result: string[] = []
125+
for (let i = 0; i < width; i++) {
126+
result.push(i in value ? printer(value[i], config, depth, refs) : '')
127+
}
128+
if (width < value.length) {
129+
result.push(`…(${value.length - width})`)
130+
}
131+
return withSpacing(result)
132+
}
133+
134+
function printObjectProperties(
135+
value: Record<string, unknown>,
136+
config: Config,
137+
depth: number,
138+
refs: Refs,
139+
): string {
140+
const keys = Object.keys(value)
141+
const width = Math.min(keys.length, config.maxWidth)
142+
const result: string[] = []
143+
for (let i = 0; i < width; i++) {
144+
const key = keys[i]
145+
const name =
146+
key !== '__proto__' && identifierRE.test(key) ? key : JSON.stringify(key)
147+
result.push(`${name}: ${printer(value[key], config, depth, refs)}`)
148+
}
149+
if (width < keys.length) {
150+
result.push(`…(${keys.length - width})`)
151+
}
152+
return withSpacing(result)
153+
}
154+
155+
function withSpacing(values: string[]): string {
156+
return values.length === 0 ? '' : ` ${values.join(', ')} `
157+
}

‎playground/forward-console/__test__/forward-console.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ test.runIf(isServe)('console.error', async () => {
5555
await expect
5656
.poll(() => normalizeLogs(serverLogs.slice(logIndex)))
5757
.toContain(
58-
`[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`,
58+
`[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`,
5959
)
6060
})
6161

0 commit comments

Comments
 (0)