diff --git a/esm/wrapper.d.ts b/esm/wrapper.d.ts index 2478715..16d6aa8 100644 --- a/esm/wrapper.d.ts +++ b/esm/wrapper.d.ts @@ -7,6 +7,7 @@ export interface StringifyOptions { deterministic?: boolean, maximumBreadth?: number, maximumDepth?: number, + strict?: boolean, } export namespace stringify { diff --git a/index.d.ts b/index.d.ts index 2478715..16d6aa8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,6 +7,7 @@ export interface StringifyOptions { deterministic?: boolean, maximumBreadth?: number, maximumDepth?: number, + strict?: boolean, } export namespace stringify { diff --git a/index.js b/index.js index 7b3b1fb..a081ba4 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,7 @@ 'use strict' +const { hasOwnProperty } = Object.prototype + const stringify = configure() // @ts-expect-error @@ -20,7 +22,7 @@ module.exports = stringify // eslint-disable-next-line 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]|[\ud800-\udbff](?![\udc00-\udfff])|(?:[^\ud800-\udbff]|^)[\udc00-\udfff]/g +const strEscapeSequencesReplacer = new RegExp(strEscapeSequencesRegExp, 'g') // Escaped special characters. Use empty strings to fill up unused entries. const meta = [ @@ -69,13 +71,13 @@ function strEscape (str) { last = i + 1 } else if (point >= 0xd800 && point <= 0xdfff) { if (point <= 0xdbff && i + 1 < str.length) { - const point = str.charCodeAt(i + 1) - if (point >= 0xdc00 && point <= 0xdfff) { + const nextPoint = str.charCodeAt(i + 1) + if (nextPoint >= 0xdc00 && nextPoint <= 0xdfff) { i++ continue } } - result += `${str.slice(last, i)}${`\\u${point.toString(16)}`}` + result += `${str.slice(last, i)}\\u${point.toString(16)}` last = i + 1 } } @@ -105,7 +107,7 @@ const typedArrayPrototypeGetSymbolToStringTag = Object.getOwnPropertyDescriptor( Object.getPrototypeOf( Object.getPrototypeOf( - new Uint8Array() + new Int8Array() ) ), Symbol.toStringTag @@ -128,8 +130,8 @@ function stringifyTypedArray (array, separator, maximumBreadth) { } function getCircularValueOption (options) { - if (options && Object.prototype.hasOwnProperty.call(options, 'circularValue')) { - var circularValue = options.circularValue + if (options && hasOwnProperty.call(options, 'circularValue')) { + const circularValue = options.circularValue if (typeof circularValue === 'string') { return `"${circularValue}"` } @@ -149,8 +151,9 @@ function getCircularValueOption (options) { } function getBooleanOption (options, key) { - if (options && Object.prototype.hasOwnProperty.call(options, key)) { - var value = options[key] + let value + if (options && hasOwnProperty.call(options, key)) { + value = options[key] if (typeof value !== 'boolean') { throw new TypeError(`The "${key}" argument must be of type boolean`) } @@ -159,8 +162,9 @@ function getBooleanOption (options, key) { } function getPositiveIntegerOption (options, key) { - if (options && Object.prototype.hasOwnProperty.call(options, key)) { - var value = options[key] + let value + if (options && hasOwnProperty.call(options, key)) { + value = options[key] if (typeof value !== 'number') { throw new TypeError(`The "${key}" argument must be of type number`) } @@ -184,16 +188,40 @@ function getItemCount (number) { function getUniqueReplacerSet (replacerArray) { const replacerSet = new Set() for (const value of replacerArray) { - if (typeof value === 'string') { - replacerSet.add(value) - } else if (typeof value === 'number') { + if (typeof value === 'string' || typeof value === 'number') { replacerSet.add(String(value)) } } return replacerSet } +function getStrictOption (options) { + if (options && hasOwnProperty.call(options, 'strict')) { + const value = options.strict + if (typeof value !== 'boolean') { + throw new TypeError('The "strict" argument must be of type boolean') + } + if (value) { + return (value) => { + let message = `Object can not safely be stringified. Received type ${typeof value}` + if (typeof value !== 'function') message += ` (${value.toString()})` + throw new Error(message) + } + } + } +} + function configure (options) { + options = { ...options } + const fail = getStrictOption(options) + if (fail) { + if (options.bigint === undefined) { + options.bigint = false + } + if (!('circularValue' in options)) { + options.circularValue = Error + } + } const circularValue = getCircularValueOption(options) const bigint = getBooleanOption(options, 'bigint') const deterministic = getBooleanOption(options, 'deterministic') @@ -302,11 +330,18 @@ function configure (options) { return `{${res}}` } case 'number': - return isFinite(value) ? String(value) : 'null' + return isFinite(value) ? String(value) : fail ? fail(value) : 'null' case 'boolean': return value === true ? 'true' : 'false' + case 'undefined': + return undefined case 'bigint': - return bigint ? String(value) : undefined + if (bigint) { + return String(value) + } + // fallthrough + default: + return fail ? fail(value) : undefined } } @@ -387,11 +422,18 @@ function configure (options) { return `{${res}}` } case 'number': - return isFinite(value) ? String(value) : 'null' + return isFinite(value) ? String(value) : fail ? fail(value) : 'null' case 'boolean': return value === true ? 'true' : 'false' + case 'undefined': + return undefined case 'bigint': - return bigint ? String(value) : undefined + if (bigint) { + return String(value) + } + // fallthrough + default: + return fail ? fail(value) : undefined } } @@ -490,11 +532,18 @@ function configure (options) { return `{${res}}` } case 'number': - return isFinite(value) ? String(value) : 'null' + return isFinite(value) ? String(value) : fail ? fail(value) : 'null' case 'boolean': return value === true ? 'true' : 'false' + case 'undefined': + return undefined case 'bigint': - return bigint ? String(value) : undefined + if (bigint) { + return String(value) + } + // fallthrough + default: + return fail ? fail(value) : undefined } } @@ -583,11 +632,18 @@ function configure (options) { return `{${res}}` } case 'number': - return isFinite(value) ? String(value) : 'null' + return isFinite(value) ? String(value) : fail ? fail(value) : 'null' case 'boolean': return value === true ? 'true' : 'false' + case 'undefined': + return undefined case 'bigint': - return bigint ? String(value) : undefined + if (bigint) { + return String(value) + } + // fallthrough + default: + return fail ? fail(value) : undefined } } diff --git a/readme.md b/readme.md index 69ed2b2..7fc8419 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,12 @@ # safe-stable-stringify -Safe, deterministic and fast serialization alternative to [JSON.stringify][]. Zero dependencies. ESM and CJS. 100% coverage. +Safe, deterministic and fast serialization alternative to [JSON.stringify][]. +Zero dependencies. ESM and CJS. 100% coverage. Gracefully handles circular structures and bigint instead of throwing. -Optional custom circular values and deterministic behavior. +Optional custom circular values, deterministic behavior or strict JSON +compatibility check. ## stringify(value[, replacer[, space]]) @@ -47,7 +49,7 @@ stringify(circular, ['a', 'b'], 2) * `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]`. + 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 @@ -58,6 +60,10 @@ stringify(circular, ['a', 'b'], 2) * `maximumDepth` {number} Maximum number of object nesting levels (at least 1) that will be serialized. Objects at the maximum level are serialized as `'[Object]'` and arrays as `'[Array]'`. **Default:** `Infinity` +* `strict` {boolean} Instead of handling any JSON value gracefully, throw an + error in case it may not be represented as JSON (functions, NaN, ...). + Circular values and bigint values throw as well in case either option is not + explicitly defined. Sets and Maps are not detected! **Default:** `false` * Returns: {function} A stringify function with the options applied. ```js @@ -101,9 +107,10 @@ throwOnCircular(circular); ## Differences to JSON.stringify -1. _Circular values_ are replaced with the string `[Circular]` (the value may be changed). -1. _Object keys_ are sorted instead of using the insertion order (it is possible to deactivate this). -1. _BigInt_ values are stringified as regular number instead of throwing a TypeError. +1. _Circular values_ are replaced with the string `[Circular]` (configurable). +1. _Object keys_ are sorted instead of using the insertion order (configurable). +1. _BigInt_ values are stringified as regular number instead of throwing a + TypeError (configurable). 1. _Boxed primitives_ (e.g., `Number(5)`) are not unboxed and are handled as regular object. diff --git a/test.js b/test.js index 3fa5eff..fbf5b59 100644 --- a/test.js +++ b/test.js @@ -1090,3 +1090,181 @@ test('check for lone surrogate pairs', (assert) => { } assert.end() }) + +test('strict option possibilities', (assert) => { + assert.throws(() => { + // @ts-expect-error + stringify.configure({ strict: 1 }) + }, { + message: 'The "strict" argument must be of type boolean', + name: 'TypeError' + }) + + const serializer = stringify.configure({ strict: false }) + + serializer(NaN) + + const strictWithoutBigInt = stringify.configure({ strict: true, bigint: true }) + strictWithoutBigInt(5n) + + assert.throws(() => { + strictWithoutBigInt(NaN) + }, { + message: 'Object can not safely be stringified. Received type number (NaN)' + }) + + const strictWithoutCircular = stringify.configure({ strict: true, circularValue: 'Circular' }) + strictWithoutBigInt(5n) + + const circular = {} + circular.circular = circular + strictWithoutCircular(circular) + + assert.end() +}) + +test('strict option simple', (assert) => { + const strictSerializer = stringify.configure({ strict: true }) + + assert.throws(() => { + strictSerializer({ a: NaN }) + }, { + message: 'Object can not safely be stringified. Received type number (NaN)', + name: 'Error' + }) + + assert.throws(() => { + strictSerializer({ a: 5n }) + }, { + message: 'Object can not safely be stringified. Received type bigint (5)', + name: 'Error' + }) + + assert.throws(() => { + strictSerializer({ a () {} }) + }, { + message: 'Object can not safely be stringified. Received type function', + name: 'Error' + }) + + assert.throws(() => { + const circular = {} + circular.circular = circular + strictSerializer(circular) + }, { + message: 'Converting circular structure to JSON', + name: 'TypeError' + }) + + assert.end() +}) + +test('strict option indentation', (assert) => { + const strictSerializer = stringify.configure({ strict: true }) + + assert.throws(() => { + strictSerializer({ a: -Infinity }, null, 2) + }, { + message: 'Object can not safely be stringified. Received type number (-Infinity)', + name: 'Error' + }) + + assert.throws(() => { + strictSerializer({ a: 5n }, null, 2) + }, { + message: 'Object can not safely be stringified. Received type bigint (5)', + name: 'Error' + }) + + assert.throws(() => { + strictSerializer({ a () {} }, null, 2) + }, { + message: 'Object can not safely be stringified. Received type function', + name: 'Error' + }) + + assert.throws(() => { + const circular = {} + circular.circular = circular + strictSerializer(circular, null, 2) + }, { + message: 'Converting circular structure to JSON', + name: 'TypeError' + }) + + assert.end() +}) + +test('strict option replacer function', (assert) => { + const strictSerializer = stringify.configure({ strict: true }) + + assert.throws(() => { + strictSerializer(Symbol('test'), (_key_, value) => value) + }, { + message: 'Object can not safely be stringified. Received type symbol (Symbol(test))' + }) + + assert.throws(() => { + strictSerializer(5n, (_key_, value) => value) + }, { + message: 'Object can not safely be stringified. Received type bigint (5)' + }) + + assert.throws(() => { + strictSerializer(NaN, (_key_, value) => value) + }, { + message: 'Object can not safely be stringified. Received type number (NaN)' + }) + + assert.throws(() => { + const circular = {} + circular.circular = circular + strictSerializer(circular, (_key_, value) => value) + }, { + message: 'Converting circular structure to JSON', + name: 'TypeError' + }) + + assert.end() +}) + +test('strict option replacer array', (assert) => { + assert.throws(() => { + // @ts-expect-error + stringify.configure({ strict: 1 }) + }, { + message: 'The "strict" argument must be of type boolean', + name: 'Error' + }) + + const strictSerializer = stringify.configure({ strict: true }) + + assert.throws(() => { + strictSerializer({ a () {} }, ['a']) + }, { + message: 'Object can not safely be stringified. Received type function' + }) + + assert.throws(() => { + strictSerializer({ a: 5n }, ['a']) + }, { + message: 'Object can not safely be stringified. Received type bigint (5)' + }) + + assert.throws(() => { + strictSerializer({ a: Infinity }, ['a']) + }, { + message: 'Object can not safely be stringified. Received type number (Infinity)' + }) + + assert.throws(() => { + const circular = {} + circular.circular = circular + strictSerializer(circular, ['circular']) + }, { + message: 'Converting circular structure to JSON', + name: 'TypeError' + }) + + assert.end() +})