Skip to content

Commit bafd93d

Browse files
committed
Add safe option to guard against getters and toJSON failures
As drive-by improve the benchmark suite slightly.
1 parent 0c192c2 commit bafd93d

File tree

8 files changed

+100
-32
lines changed

8 files changed

+100
-32
lines changed

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# Changelog
22

3+
## v2.6.0
4+
5+
- Added `safe` option to not fail in case a getter or `.toJSON()` throws an error.
6+
Instead, a string as error message is replacing the object inspection. This allows to partially inspect such objects.
7+
The default is `false` to prevent any breaking change.
8+
9+
```js
10+
import { configure } from 'safe-stable-stringify'
11+
12+
const stringify = configure({
13+
safe: true
14+
})
15+
16+
stringify([{
17+
foo: { a: 5, get foo() { throw new Error('Oops') }, c: true }
18+
}])
19+
// '[{"foo":"Error: Stringification failed. Message: Oops"}]'
20+
21+
stringify([{
22+
foo: { a: 5, toJSON() { throw new Error('Oops') }, c: true }
23+
}])
24+
// '[{"foo":"Error: Stringification failed. Message: Oops"}]'
25+
```
26+
327
## v2.5.0
428

529
- Accept `Array#sort(comparator)` comparator method as deterministic option value to use that comparator for sorting object keys.

benchmark.js

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use strict'
22

3-
const Benchmark = require('benchmark')
3+
const Benchmark = require('bench-node')
44
const suite = new Benchmark.Suite()
55
const stringify = require('.').configure({ deterministic: true })
66

7-
// eslint-disable-next-line
87
const array = Array.from({ length: 10 }, (_, i) => i)
98
const obj = { array }
109
const circ = JSON.parse(JSON.stringify(obj))
@@ -106,14 +105,4 @@ suite.add('indentation: deep circular', function () {
106105
stringify(deepCirc, null, 2)
107106
})
108107

109-
// add listeners
110-
suite.on('cycle', function (event) {
111-
console.log(String(event.target))
112-
})
113-
114-
suite.on('complete', function () {
115-
console.log('\nBenchmark done')
116-
// console.log('\nFastest is ' + this.filter('fastest').map('name'))
117-
})
118-
119-
suite.run({ delay: 1, minSamples: 150 })
108+
suite.run()

compare.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const Benchmark = require('benchmark')
3+
const Benchmark = require('bench-node')
44
const suite = new Benchmark.Suite()
55
const testData = require('./test.json')
66

@@ -13,7 +13,7 @@ const stringifyPackages = {
1313
'faster-stable-stringify': true,
1414
'json-stringify-deterministic': true,
1515
'fast-safe-stringify': 'stable',
16-
this: require('.')
16+
'safe-stable-stringify': require('.')
1717
}
1818

1919
for (const name in stringifyPackages) {
@@ -31,9 +31,4 @@ for (const name in stringifyPackages) {
3131
})
3232
}
3333

34-
suite
35-
.on('cycle', (event) => console.log(String(event.target)))
36-
.on('complete', function () {
37-
console.log('\nThe fastest is ' + this.filter('fastest').map('name'))
38-
})
39-
.run({ async: true, delay: 5, minSamples: 150 })
34+
suite.run()

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface StringifyOptions {
1111
maximumBreadth?: number,
1212
maximumDepth?: number,
1313
strict?: boolean,
14+
safe?: boolean,
1415
}
1516

1617
export namespace stringify {

index.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,15 @@ function getDeterministicOption (options) {
108108
return value === undefined ? true : value
109109
}
110110

111-
function getBooleanOption (options, key) {
111+
function getBooleanOption (options, key, defaultValue = true) {
112112
let value
113113
if (hasOwnProperty.call(options, key)) {
114114
value = options[key]
115115
if (typeof value !== 'boolean') {
116116
throw new TypeError(`The "${key}" argument must be of type boolean`)
117117
}
118118
}
119-
return value === undefined ? true : value
119+
return value === undefined ? defaultValue : value
120120
}
121121

122122
function getPositiveIntegerOption (options, key) {
@@ -169,6 +169,19 @@ function getStrictOption (options) {
169169
}
170170
}
171171

172+
function makeSafe (method) {
173+
return function (...input) {
174+
try {
175+
return method(...input)
176+
} catch (error) {
177+
const message = typeof error?.message === 'string'
178+
? error.message
179+
: (() => { try { return String(error) } catch { return 'Failed' } })()
180+
return strEscape('Error: Stringification failed. Message: ' + message)
181+
}
182+
}
183+
}
184+
172185
function configure (options) {
173186
options = { ...options }
174187
const fail = getStrictOption(options)
@@ -186,8 +199,9 @@ function configure (options) {
186199
const comparator = typeof deterministic === 'function' ? deterministic : undefined
187200
const maximumDepth = getPositiveIntegerOption(options, 'maximumDepth')
188201
const maximumBreadth = getPositiveIntegerOption(options, 'maximumBreadth')
202+
const safe = getBooleanOption(options, 'safe', false)
189203

190-
function stringifyFnReplacer (key, parent, stack, replacer, spacer, indentation) {
204+
let stringifyFnReplacer = function (key, parent, stack, replacer, spacer, indentation) {
191205
let value = parent[key]
192206

193207
if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') {
@@ -298,7 +312,7 @@ function configure (options) {
298312
}
299313
}
300314

301-
function stringifyArrayReplacer (key, value, stack, replacer, spacer, indentation) {
315+
let stringifyArrayReplacer = function (key, value, stack, replacer, spacer, indentation) {
302316
if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') {
303317
value = value.toJSON(key)
304318
}
@@ -387,7 +401,7 @@ function configure (options) {
387401
}
388402
}
389403

390-
function stringifyIndent (key, value, stack, spacer, indentation) {
404+
let stringifyIndent = function (key, value, stack, spacer, indentation) {
391405
switch (typeof value) {
392406
case 'string':
393407
return strEscape(value)
@@ -497,7 +511,7 @@ function configure (options) {
497511
}
498512
}
499513

500-
function stringifySimple (key, value, stack) {
514+
let stringifySimple = function (key, value, stack) {
501515
switch (typeof value) {
502516
case 'string':
503517
return strEscape(value)
@@ -598,6 +612,13 @@ function configure (options) {
598612
}
599613
}
600614

615+
if (safe) {
616+
stringifyFnReplacer = makeSafe(stringifyFnReplacer)
617+
stringifyArrayReplacer = makeSafe(stringifyArrayReplacer)
618+
stringifyIndent = makeSafe(stringifyIndent)
619+
stringifySimple = makeSafe(stringifySimple)
620+
}
621+
601622
function stringify (value, replacer, space) {
602623
if (arguments.length > 1) {
603624
let spacer = ''

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
"test": "standard && tap test.js",
2727
"tap": "tap test.js",
2828
"tap:only": "tap test.js --watch --only",
29-
"benchmark": "node benchmark.js",
30-
"compare": "node compare.js",
29+
"benchmark": "node --allow-natives-syntax benchmark.js",
30+
"compare": "node --allow-natives-syntax compare.js",
3131
"lint": "standard --fix",
3232
"tsc": "tsc --project tsconfig.json"
3333
},
@@ -40,7 +40,6 @@
4040
"devDependencies": {
4141
"@types/json-stable-stringify": "^1.0.34",
4242
"@types/node": "^18.11.18",
43-
"benchmark": "^2.1.4",
4443
"clone": "^2.1.2",
4544
"fast-json-stable-stringify": "^2.1.0",
4645
"fast-safe-stringify": "^2.1.1",
@@ -50,6 +49,7 @@
5049
"json-stable-stringify": "^1.0.1",
5150
"json-stringify-deterministic": "^1.0.7",
5251
"json-stringify-safe": "^5.0.1",
52+
"bench-node": "^0.5.4",
5353
"standard": "^16.0.4",
5454
"tap": "^15.0.9",
5555
"typescript": "^4.8.3"

readme.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ stringify(circular, ['a', 'b'], 2)
4444

4545
## stringify.configure(options)
4646

47-
* `bigint` {boolean} If `true`, bigint values are converted to a number. Otherwise
48-
they are ignored. **Default:** `true`.
47+
* `bigint` {boolean} If `true`, bigint values are converted to a number.
48+
Otherwise they are ignored. **Default:** `true`.
4949
* `circularValue` {string|null|undefined|ErrorConstructor} Defines the value for
5050
circular references. Set to `undefined`, circular properties are not
5151
serialized (array entries are replaced with `null`). Set to `Error`, to throw
@@ -66,6 +66,9 @@ stringify(circular, ['a', 'b'], 2)
6666
Circular values and bigint values throw as well in case either option is not
6767
explicitly defined. Sets and Maps are not detected as well as Symbol keys!
6868
**Default:** `false`
69+
* `safe` {boolean} If `true`, calls to .toJSON() and getters that throw an error
70+
are going to return the error message as content in place of the object
71+
instead of throwing the error. **Default:** `false`
6972
* Returns: {function} A stringify function with the options applied.
7073

7174
```js

test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,3 +1344,38 @@ test('deterministic custom sorting', function (assert) {
13441344

13451345
assert.end()
13461346
})
1347+
1348+
test('safe mode safeguards against failing getters', function (assert) {
1349+
const serializer = stringify.configure({ safe: true })
1350+
1351+
const obj = { b: 2, c: { a: true, get b () { throw new Error('Oops') } }, a: 1 }
1352+
const expected = '{\n "a": 1,\n "b": 2,\n "c": "Error: Stringification failed. Message: Oops"\n}'
1353+
const actual = serializer(obj, null, 1)
1354+
assert.equal(actual, expected)
1355+
1356+
assert.end()
1357+
})
1358+
1359+
test('safe mode safeguards against failing toJSON method', function (assert) {
1360+
const serializer = stringify.configure({ safe: true })
1361+
1362+
// eslint-disable-next-line
1363+
const obj = { b: 2, c: { a: true, toJSON () { throw 'Oops' } }, a: 1 }
1364+
const expected = '{\n "a": 1,\n "b": 2,\n "c": "Error: Stringification failed. Message: Oops"\n}'
1365+
const actual = serializer(obj, null, 1)
1366+
assert.equal(actual, expected)
1367+
1368+
assert.end()
1369+
})
1370+
1371+
test('safe mode safeguards against failing getters and a difficult to stringify error', function (assert) {
1372+
const serializer = stringify.configure({ safe: true })
1373+
1374+
// eslint-disable-next-line
1375+
const obj = { b: 2, c: { a: true, toJSON () { throw { toString() { throw new Error('Yikes') } } } }, a: 1 }
1376+
const expected = '{\n "a": 1,\n "b": 2,\n "c": "Error: Stringification failed. Message: Failed"\n}'
1377+
const actual = serializer(obj, null, 1)
1378+
assert.equal(actual, expected)
1379+
1380+
assert.end()
1381+
})

0 commit comments

Comments
 (0)