Skip to content

Commit 44b72a2

Browse files
committed
Add support to throw on circular values and fix surrogate pairs
1 parent 4db216f commit 44b72a2

File tree

8 files changed

+120
-17
lines changed

8 files changed

+120
-17
lines changed

CHANGELOG.md

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

3+
## v2.3.0
4+
5+
- Accept the `Error` constructor as `circularValue` option to throw on circular references as the regular JSON.stringify would:
6+
7+
```js
8+
import { configure } from 'safe-stable-stringify'
9+
10+
const object = {}
11+
object.circular = object;
12+
13+
const stringify = configure({ circularValue: TypeError })
14+
15+
stringify(object)
16+
// TypeError: Converting circular structure to JSON
17+
```
18+
19+
- Fixed escaping wrong surrogates. Only lone surrogates are now escaped.
20+
321
## v2.2.0
422

523
- Reduce module size by removing the test and benchmark files from the published package

compare.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const testData = require('./test.json')
66

77
const stringifyPackages = {
88
// 'JSON.stringify': JSON.stringify,
9+
'fastest-stable-stringify': true,
910
'fast-json-stable-stringify': true,
1011
'json-stable-stringify': true,
1112
'fast-stable-stringify': true,

index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export function stringify(value: any, replacer?: (number | string)[] | null, spa
33

44
export interface StringifyOptions {
55
bigint?: boolean,
6-
circularValue?: string | null,
6+
circularValue?: string | null | TypeErrorConstructor | ErrorConstructor,
77
deterministic?: boolean,
88
maximumBreadth?: number,
99
maximumDepth?: number,

index.js

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ exports.configure = configure
1818
module.exports = stringify
1919

2020
// eslint-disable-next-line
21-
const strEscapeSequencesRegExp = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/
21+
const strEscapeSequencesRegExp = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/
2222
// eslint-disable-next-line
23-
const strEscapeSequencesReplacer = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/g
23+
const strEscapeSequencesReplacer = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
2424

2525
// Escaped special characters. Use empty strings to fill up unused entries.
2626
const meta = [
@@ -43,7 +43,7 @@ function escapeFn (str) {
4343
const charCode = str.charCodeAt(0)
4444
return meta.length > charCode
4545
? meta[charCode]
46-
: `\\u${charCode.toString(16).padStart(4, '0')}`
46+
: `\\u${charCode.toString(16)}`
4747
}
4848

4949
// Escape C0 control characters, double quotes, the backslash and every code
@@ -63,8 +63,15 @@ function strEscape (str) {
6363
if (point === 34 || point === 92 || point < 32) {
6464
result += `${str.slice(last, i)}${meta[point]}`
6565
last = i + 1
66-
} else if (point >= 55296 && point <= 57343) {
67-
result += `${str.slice(last, i)}${`\\u${point.toString(16).padStart(4, '0')}`}`
66+
} else if (point >= 0xd800 && point <= 0xdfff) {
67+
if (point <= 0xdbff && i + 1 < str.length) {
68+
const point = str.charCodeAt(i + 1)
69+
if (point >= 0xdc00 && point <= 0xdfff) {
70+
i++
71+
continue
72+
}
73+
}
74+
result += `${str.slice(last, i)}${`\\u${point.toString(16)}`}`
6875
last = i + 1
6976
}
7077
}
@@ -120,14 +127,21 @@ function getCircularValueOption (options) {
120127
if (options && Object.prototype.hasOwnProperty.call(options, 'circularValue')) {
121128
var circularValue = options.circularValue
122129
if (typeof circularValue === 'string') {
123-
circularValue = `"${circularValue}"`
124-
} else if (circularValue === undefined) {
125-
return
126-
} else if (circularValue !== null) {
127-
throw new TypeError('The "circularValue" argument must be of type string or the value null or undefined')
130+
return `"${circularValue}"`
131+
}
132+
if (circularValue == null) {
133+
return circularValue
134+
}
135+
if (circularValue === Error || circularValue === TypeError) {
136+
return {
137+
toString () {
138+
throw new TypeError('Converting circular structure to JSON')
139+
}
140+
}
128141
}
142+
throw new TypeError('The "circularValue" argument must be of type string or the value null or undefined')
129143
}
130-
return circularValue === undefined ? '"[Circular]"' : circularValue
144+
return '"[Circular]"'
131145
}
132146

133147
function getBooleanOption (options, key) {

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"benchmark": "node benchmark.js",
3030
"compare": "node compare.js",
3131
"lint": "standard --fix",
32-
"tsc": "tsc"
32+
"tsc": "tsc --project tsconfig.json"
3333
},
3434
"engines": {
3535
"node": ">=10"
@@ -46,6 +46,7 @@
4646
"fast-safe-stringify": "^2.0.7",
4747
"fast-stable-stringify": "^1.0.0",
4848
"faster-stable-stringify": "^1.0.0",
49+
"fastest-stable-stringify": "^2.0.2",
4950
"json-stable-stringify": "^1.0.1",
5051
"json-stringify-deterministic": "^1.0.1",
5152
"json-stringify-safe": "^5.0.1",

readme.md

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

4545
* `bigint` {boolean} If `true`, bigint values are converted to a number. Otherwise
4646
they are ignored. **Default:** `true`.
47-
* `circularValue` {string|null|undefined} Defines the value for circular
48-
references. Set to `undefined`, circular properties are not serialized (array
49-
entries are replaced with `null`). **Default:** `[Circular]`.
47+
* `circularValue` {string|null|undefined|ErrorConstructor} Defines the value for
48+
circular references. Set to `undefined`, circular properties are not
49+
serialized (array entries are replaced with `null`). Set to `Error`, to throw
50+
on circular references. **Default:** `[Circular]`.
5051
* `deterministic` {boolean} If `true`, guarantee a deterministic key order
5152
instead of relying on the insertion order. **Default:** `true`.
5253
* `maximumBreadth` {number} Maximum number of entries to serialize per object
@@ -89,6 +90,13 @@ console.log(stringified)
8990
// "circular": "Magic circle!",
9091
// "...": "2 items not stringified"
9192
// }
93+
94+
const throwOnCircular = configure({
95+
circularValue: Error
96+
})
97+
98+
throwOnCircular(circular);
99+
// TypeError: Converting circular structure to JSON
92100
```
93101

94102
## Differences to JSON.stringify

test.js

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ test('nested circular reference to root', function (assert) {
2424
assert.end()
2525
})
2626

27+
test('throw if circularValue is set to TypeError', function (assert) {
28+
const noCircularStringify = stringify.configure({ circularValue: TypeError })
29+
const object = { number: 42, boolean: true, string: 'Yes!' }
30+
object.circular = object
31+
32+
assert.throws(() => noCircularStringify(object), TypeError)
33+
assert.end()
34+
})
35+
36+
test('throw if circularValue is set to Error', function (assert) {
37+
const noCircularStringify = stringify.configure({ circularValue: Error })
38+
const object = { number: 42, boolean: true, string: 'Yes!' }
39+
object.circular = object
40+
41+
assert.throws(() => noCircularStringify(object), TypeError)
42+
assert.end()
43+
})
44+
2745
test('child circular reference', function (assert) {
2846
const fixture = { name: 'Tywin Lannister', child: { name: 'Tyrion\n\t"Lannister'.repeat(20) } }
2947
fixture.child.dinklage = fixture.child
@@ -1013,7 +1031,7 @@ test('should throw when maximumBreadth receives malformed input', (assert) => {
10131031
assert.end()
10141032
})
10151033

1016-
test('check for well formed stringify implementation', (assert) => {
1034+
test('check that all single characters are identical to JSON.stringify', (assert) => {
10171035
for (let i = 0; i < 2 ** 16; i++) {
10181036
const string = String.fromCharCode(i)
10191037
const actual = stringify(string)
@@ -1030,3 +1048,45 @@ test('check for well formed stringify implementation', (assert) => {
10301048
assert.equal(longStringEscape, `"${'a'.repeat(100)}\\ud800"`)
10311049
assert.end()
10321050
})
1051+
1052+
test('check for lone surrogate pairs', (assert) => {
1053+
const edgeChar = String.fromCharCode(0xd799)
1054+
1055+
for (let charCode = 0xD800; charCode < 0xDFFF; charCode++) {
1056+
const surrogate = String.fromCharCode(charCode)
1057+
1058+
assert.equal(
1059+
stringify(surrogate),
1060+
`"\\u${charCode.toString(16)}"`
1061+
)
1062+
assert.equal(
1063+
stringify(`${'a'.repeat(200)}${surrogate}`),
1064+
`"${'a'.repeat(200)}\\u${charCode.toString(16)}"`
1065+
)
1066+
assert.equal(
1067+
stringify(`${surrogate}${'a'.repeat(200)}`),
1068+
`"\\u${charCode.toString(16)}${'a'.repeat(200)}"`
1069+
)
1070+
if (charCode < 0xdc00) {
1071+
const highSurrogate = surrogate
1072+
const lowSurrogate = String.fromCharCode(charCode + 1024)
1073+
assert.notOk(
1074+
stringify(
1075+
`${edgeChar}${highSurrogate}${lowSurrogate}${edgeChar}`
1076+
).includes('\\u')
1077+
)
1078+
assert.equal(
1079+
(stringify(
1080+
`${highSurrogate}${highSurrogate}${lowSurrogate}`
1081+
).match(/\\u/g) || []).length,
1082+
1
1083+
)
1084+
} else {
1085+
assert.equal(
1086+
stringify(`${edgeChar}${surrogate}${edgeChar}`),
1087+
`"${edgeChar}\\u${charCode.toString(16)}${edgeChar}"`
1088+
)
1089+
}
1090+
}
1091+
assert.end()
1092+
})

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@
1717
"dom"
1818
]
1919
},
20+
"include": ["**/*.js", "**/*.ts"],
2021
"exclude": ["compare.js", "benchmark.js", "./coverage"]
2122
}

0 commit comments

Comments
 (0)