Skip to content

Commit

Permalink
Add support to throw on circular values and fix surrogate pairs
Browse files Browse the repository at this point in the history
  • Loading branch information
BridgeAR committed Nov 29, 2021
1 parent 4db216f commit 44b72a2
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 17 deletions.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## v2.3.0

- Accept the `Error` constructor as `circularValue` option to throw on circular references as the regular JSON.stringify would:

```js
import { configure } from 'safe-stable-stringify'

const object = {}
object.circular = object;

const stringify = configure({ circularValue: TypeError })

stringify(object)
// TypeError: Converting circular structure to JSON
```

- Fixed escaping wrong surrogates. Only lone surrogates are now escaped.

## v2.2.0

- Reduce module size by removing the test and benchmark files from the published package
Expand Down
1 change: 1 addition & 0 deletions compare.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const testData = require('./test.json')

const stringifyPackages = {
// 'JSON.stringify': JSON.stringify,
'fastest-stable-stringify': true,
'fast-json-stable-stringify': true,
'json-stable-stringify': true,
'fast-stable-stringify': true,
Expand Down
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export function stringify(value: any, replacer?: (number | string)[] | null, spa

export interface StringifyOptions {
bigint?: boolean,
circularValue?: string | null,
circularValue?: string | null | TypeErrorConstructor | ErrorConstructor,
deterministic?: boolean,
maximumBreadth?: number,
maximumDepth?: number,
Expand Down
36 changes: 25 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ exports.configure = configure
module.exports = stringify

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

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

// Escape C0 control characters, double quotes, the backslash and every code
Expand All @@ -63,8 +63,15 @@ function strEscape (str) {
if (point === 34 || point === 92 || point < 32) {
result += `${str.slice(last, i)}${meta[point]}`
last = i + 1
} else if (point >= 55296 && point <= 57343) {
result += `${str.slice(last, i)}${`\\u${point.toString(16).padStart(4, '0')}`}`
} else if (point >= 0xd800 && point <= 0xdfff) {
if (point <= 0xdbff && i + 1 < str.length) {
const point = str.charCodeAt(i + 1)
if (point >= 0xdc00 && point <= 0xdfff) {
i++
continue
}
}
result += `${str.slice(last, i)}${`\\u${point.toString(16)}`}`
last = i + 1
}
}
Expand Down Expand Up @@ -120,14 +127,21 @@ function getCircularValueOption (options) {
if (options && Object.prototype.hasOwnProperty.call(options, 'circularValue')) {
var circularValue = options.circularValue
if (typeof circularValue === 'string') {
circularValue = `"${circularValue}"`
} else if (circularValue === undefined) {
return
} else if (circularValue !== null) {
throw new TypeError('The "circularValue" argument must be of type string or the value null or undefined')
return `"${circularValue}"`
}
if (circularValue == null) {
return circularValue
}
if (circularValue === Error || circularValue === TypeError) {
return {
toString () {
throw new TypeError('Converting circular structure to JSON')
}
}
}
throw new TypeError('The "circularValue" argument must be of type string or the value null or undefined')
}
return circularValue === undefined ? '"[Circular]"' : circularValue
return '"[Circular]"'
}

function getBooleanOption (options, key) {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"benchmark": "node benchmark.js",
"compare": "node compare.js",
"lint": "standard --fix",
"tsc": "tsc"
"tsc": "tsc --project tsconfig.json"
},
"engines": {
"node": ">=10"
Expand All @@ -46,6 +46,7 @@
"fast-safe-stringify": "^2.0.7",
"fast-stable-stringify": "^1.0.0",
"faster-stable-stringify": "^1.0.0",
"fastest-stable-stringify": "^2.0.2",
"json-stable-stringify": "^1.0.1",
"json-stringify-deterministic": "^1.0.1",
"json-stringify-safe": "^5.0.1",
Expand Down
14 changes: 11 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ stringify(circular, ['a', 'b'], 2)

* `bigint` {boolean} If `true`, bigint values are converted to a number. Otherwise
they are ignored. **Default:** `true`.
* `circularValue` {string|null|undefined} Defines the value for circular
references. Set to `undefined`, circular properties are not serialized (array
entries are replaced with `null`). **Default:** `[Circular]`.
* `circularValue` {string|null|undefined|ErrorConstructor} Defines the value for
circular references. Set to `undefined`, circular properties are not
serialized (array entries are replaced with `null`). Set to `Error`, to throw
on circular references. **Default:** `[Circular]`.
* `deterministic` {boolean} If `true`, guarantee a deterministic key order
instead of relying on the insertion order. **Default:** `true`.
* `maximumBreadth` {number} Maximum number of entries to serialize per object
Expand Down Expand Up @@ -89,6 +90,13 @@ console.log(stringified)
// "circular": "Magic circle!",
// "...": "2 items not stringified"
// }

const throwOnCircular = configure({
circularValue: Error
})

throwOnCircular(circular);
// TypeError: Converting circular structure to JSON
```

## Differences to JSON.stringify
Expand Down
62 changes: 61 additions & 1 deletion test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ test('nested circular reference to root', function (assert) {
assert.end()
})

test('throw if circularValue is set to TypeError', function (assert) {
const noCircularStringify = stringify.configure({ circularValue: TypeError })
const object = { number: 42, boolean: true, string: 'Yes!' }
object.circular = object

assert.throws(() => noCircularStringify(object), TypeError)
assert.end()
})

test('throw if circularValue is set to Error', function (assert) {
const noCircularStringify = stringify.configure({ circularValue: Error })
const object = { number: 42, boolean: true, string: 'Yes!' }
object.circular = object

assert.throws(() => noCircularStringify(object), TypeError)
assert.end()
})

test('child circular reference', function (assert) {
const fixture = { name: 'Tywin Lannister', child: { name: 'Tyrion\n\t"Lannister'.repeat(20) } }
fixture.child.dinklage = fixture.child
Expand Down Expand Up @@ -1013,7 +1031,7 @@ test('should throw when maximumBreadth receives malformed input', (assert) => {
assert.end()
})

test('check for well formed stringify implementation', (assert) => {
test('check that all single characters are identical to JSON.stringify', (assert) => {
for (let i = 0; i < 2 ** 16; i++) {
const string = String.fromCharCode(i)
const actual = stringify(string)
Expand All @@ -1030,3 +1048,45 @@ test('check for well formed stringify implementation', (assert) => {
assert.equal(longStringEscape, `"${'a'.repeat(100)}\\ud800"`)
assert.end()
})

test('check for lone surrogate pairs', (assert) => {
const edgeChar = String.fromCharCode(0xd799)

for (let charCode = 0xD800; charCode < 0xDFFF; charCode++) {
const surrogate = String.fromCharCode(charCode)

assert.equal(
stringify(surrogate),
`"\\u${charCode.toString(16)}"`
)
assert.equal(
stringify(`${'a'.repeat(200)}${surrogate}`),
`"${'a'.repeat(200)}\\u${charCode.toString(16)}"`
)
assert.equal(
stringify(`${surrogate}${'a'.repeat(200)}`),
`"\\u${charCode.toString(16)}${'a'.repeat(200)}"`
)
if (charCode < 0xdc00) {
const highSurrogate = surrogate
const lowSurrogate = String.fromCharCode(charCode + 1024)
assert.notOk(
stringify(
`${edgeChar}${highSurrogate}${lowSurrogate}${edgeChar}`
).includes('\\u')
)
assert.equal(
(stringify(
`${highSurrogate}${highSurrogate}${lowSurrogate}`
).match(/\\u/g) || []).length,
1
)
} else {
assert.equal(
stringify(`${edgeChar}${surrogate}${edgeChar}`),
`"${edgeChar}\\u${charCode.toString(16)}${edgeChar}"`
)
}
}
assert.end()
})
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
"dom"
]
},
"include": ["**/*.js", "**/*.ts"],
"exclude": ["compare.js", "benchmark.js", "./coverage"]
}

0 comments on commit 44b72a2

Please sign in to comment.