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
28 changes: 26 additions & 2 deletions modules/json/safe_stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,36 @@ function jsonStringifyReplacer(replacer?: JSONReplacer): JSONReplacer {

/**
* String Javascript values to a JSON string. Handles circular
* references and bigints
* references and bigints.
*
* Uses the native `JSON.stringify` first and falls back to
* "safe-stable-stringify" only when it throws a TypeError, which
* is what V8 raises for circular references and bigints. The fast
* path covers most values and is several times faster.
*
* When a replacer is defined, bigints are converted inside the
* native pass. Only circular references trigger the fallback, and
* in that case the replacer runs a second time.
*
* On Node.js 25+, a V8 bug in the fast path of `JSON.stringify`
* makes it take several milliseconds to reject a circular plain
* object. Values with circular references are slower than before
* on these versions until V8 fixes it.
* See https://issues.chromium.org/issues/572287937
*/
export function safeStringify(
value: any,
replacer?: JSONReplacer,
space?: string | number
): string | undefined {
return stringify(value, jsonStringifyReplacer(replacer), space)
const replacerFn = replacer ? jsonStringifyReplacer(replacer) : undefined

try {
return JSON.stringify(value, replacerFn, space)
} catch (error) {
if (!(error instanceof TypeError)) {
throw error
}
return stringify(value, replacerFn ?? jsonStringifyReplacer(), space)
}
}
164 changes: 164 additions & 0 deletions tests/safe_stringify.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,168 @@ test.group('Stringify', () => {
'blow up'
)
})

test('stringify nested bigints', ({ assert }) => {
assert.equal(safeStringify({ a: { b: [BigInt(1), BigInt(2)] } }), '{"a":{"b":["1","2"]}}')
})

test('stringify top-level bigint', ({ assert }) => {
assert.equal(safeStringify(BigInt(10)), '"10"')
})

test('stringify array with circular reference', ({ assert }) => {
const list: any[] = [1]
list.push(list)
assert.equal(safeStringify(list), '[1,null]')
})

test('stringify shared references that are not circular', ({ assert }) => {
const shared = { x: 1 }
assert.equal(
safeStringify({ a: shared, b: shared, c: [shared] }),
'{"a":{"x":1},"b":{"x":1},"c":[{"x":1}]}'
)
})

test('stringify lone surrogates as escape sequences', ({ assert }) => {
assert.equal(safeStringify('\ud800x\udc00'), '"\\ud800x\\udc00"')
})

test('stringify built-in objects', ({ assert }) => {
assert.equal(safeStringify({ m: new Map([[1, 2]]), s: new Set([1]) }), '{"m":{},"s":{}}')
assert.equal(safeStringify(Buffer.from('hi')), '{"type":"Buffer","data":[104,105]}')
assert.equal(safeStringify(new Error('boom')), '{}')
assert.equal(safeStringify(/x/g), '{}')
assert.equal(safeStringify({ d: new Date(Number.NaN) }), '{"d":null}')
})

test('stringify object without prototype', ({ assert }) => {
const value = Object.create(null)
value.a = 1
assert.equal(safeStringify(value), '{"a":1}')
})

test('stringify array holes and unsupported values', ({ assert }) => {
// eslint-disable-next-line no-sparse-arrays
assert.equal(safeStringify([1, , undefined, () => {}, Symbol('s')]), '[1,null,null,null,null]')
assert.equal(safeStringify([Number.NaN, Infinity, -0]), '[null,null,0]')
})

test('ignore symbol keys and non-enumerable properties', ({ assert }) => {
const value = Object.defineProperty({ a: 1, [Symbol('s')]: 2 }, 'b', {
value: 2,
enumerable: false,
})
assert.equal(safeStringify(value), '{"a":1}')
})

test('stringify getters', ({ assert }) => {
assert.equal(
safeStringify({
get g() {
return 42
},
}),
'{"g":42}'
)
})

test('stringify boxed primitives as their primitive value', ({ assert }) => {
/* eslint-disable no-new-wrappers */
const value = { s: new String('ab'), n: new Number(1), b: new Boolean(false) }
/* eslint-enable no-new-wrappers */
assert.equal(safeStringify(value), '{"s":"ab","n":1,"b":false}')
})

test('indent output using space', ({ assert }) => {
assert.equal(safeStringify({ a: [1] }, undefined, 2), '{\n "a": [\n 1\n ]\n}')
assert.equal(safeStringify({ a: 1 }, undefined, '\t'), '{\n\t"a": 1\n}')
assert.equal(safeStringify({ a: 1 }, undefined, -1), '{"a":1}')
})

test('indent output in the fallback path', ({ assert }) => {
const value: any = { a: BigInt(1) }
value.self = value
assert.equal(safeStringify(value, undefined, 2), '{\n "a": "1"\n}')
})

test('call the replacer with the holder as this', ({ assert }) => {
const holders: any[] = []
const value = { a: 1 }
safeStringify(value, function (this: any, key, val) {
holders.push([key, this])
return val
})

assert.deepEqual(holders[0], ['', { '': value }])
assert.strictEqual(holders[1][1], value)
})

test('call the replacer once per value when the value has bigints', ({ assert }) => {
const keys: string[] = []
const output = safeStringify({ a: BigInt(5), b: [BigInt(1)] }, (key, value) => {
keys.push(key)
return value
})

assert.equal(output, '{"a":"5","b":["1"]}')
assert.deepEqual(keys, ['', 'a', 'b', '0'])
})

test('pass bigints to the replacer before converting them', ({ assert }) => {
const received: string[] = []
const output = safeStringify({ a: BigInt(5) }, (_, value) => {
received.push(typeof value)
return value
})

assert.equal(output, '{"a":"5"}')
assert.deepEqual(received, ['object', 'bigint'])
})

test('convert bigints returned by the replacer', ({ assert }) => {
const replacer = (_: string, value: any) => (typeof value === 'number' ? BigInt(value) : value)
assert.equal(safeStringify({ a: 1, b: [2] }, replacer), '{"a":"1","b":["2"]}')
})

test('apply the replacer to the output when falling back for circular references', ({
assert,
}) => {
const value: any = { a: 1, b: BigInt(2) }
value.self = value

const replacer = (_: string, val: any) => (typeof val === 'number' ? val * 10 : val)
assert.equal(safeStringify(value, replacer), '{"a":10,"b":"2"}')
})

test('call the replacer again during the fallback for circular references', ({ assert }) => {
const value: any = { a: 1 }
value.self = value

let calls = 0
const output = safeStringify(value, (_, val) => {
calls++
return val
})

/**
* The native pass visits "", "a" and "self" before throwing.
* The fallback visits the same three keys.
*/
assert.equal(output, '{"a":1}')
assert.equal(calls, 6)
})

test('do not retry when the replacer throws a non TypeError', ({ assert }) => {
let calls = 0
assert.throws(
() =>
safeStringify({ a: 1 }, () => {
calls++
throw new Error('blow up')
}),
'blow up'
)
assert.equal(calls, 1)
})
})
Loading