diff --git a/CHANGELOG.md b/CHANGELOG.md index ed30801..1d5569f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/compare.js b/compare.js index 252a4a0..748c4d9 100644 --- a/compare.js +++ b/compare.js @@ -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, diff --git a/index.d.ts b/index.d.ts index 20ed31d..2478715 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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, diff --git a/index.js b/index.js index 45d30c2..1e11c2a 100644 --- a/index.js +++ b/index.js @@ -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])|(? 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 @@ -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 } } @@ -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) { diff --git a/package.json b/package.json index 31a543e..c37041f 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", diff --git a/readme.md b/readme.md index 08a015e..69ed2b2 100644 --- a/readme.md +++ b/readme.md @@ -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 @@ -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 diff --git a/test.js b/test.js index eae7b96..3fa5eff 100644 --- a/test.js +++ b/test.js @@ -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 @@ -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) @@ -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() +}) diff --git a/tsconfig.json b/tsconfig.json index 192d9ed..403127a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,6 @@ "dom" ] }, + "include": ["**/*.js", "**/*.ts"], "exclude": ["compare.js", "benchmark.js", "./coverage"] }