diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af225b2..3dc1448 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,16 +15,16 @@ jobs: os: - ubuntu-latest node_version: - - 16 - 18 - 20 - 22 - 24 + - 26 name: Running tests with Node ${{ matrix.node_version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node_version }} - run: | diff --git a/.gitignore b/.gitignore index 820fa02..a70dd01 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ npm-debug.log yarn.lock coverage .nyc_output +.tap/ .vscode package-lock.json diff --git a/benchmark-utils.js b/benchmark-utils.js new file mode 100644 index 0000000..bcb5735 --- /dev/null +++ b/benchmark-utils.js @@ -0,0 +1,34 @@ +'use strict' + +const assert = require('node:assert/strict') +const { cpus } = require('node:os') + +const { Suite } = require('bench-node') + +module.exports = function runBenchmarks (cases, seconds = 2) { + let length = 0 + const suite = new Suite({ + repeatSuite: 7, + reporter (results) { + console.log(`${cpus()[0].model}; Node ${process.version}; V8 ${process.versions.v8}`) + for (const result of results) { + const runs = result.opsSecPerRun + const mean = runs.reduce((total, value) => total + value, 0) / runs.length + const deviation = Math.sqrt(runs.reduce((total, value) => total + (value - mean) ** 2, 0) / (runs.length - 1)) + const sorted = [...runs].sort((left, right) => left - right).slice(1, -1) + const rate = sorted.reduce((total, value) => total + value, 0) / sorted.length + console.log(`${result.name.padEnd(34)} ${Math.round(rate).toLocaleString('en-US')} ops/sec (7 runs, CV ${(deviation / mean * 100).toFixed(2)}%)`) + assert.ok(deviation / mean < 0.03, `${result.name}: excessive variance; rates: ${runs.map(Math.round).join(', ')}; rerun on a quiet host`) + } + console.log('Output checksum:', length) + } + }) + for (const [name, fn] of cases) { + const expected = fn() + assert.equal(typeof expected, 'string') + for (let iteration = 0; iteration < 50000; iteration++) length ^= fn().length + assert.equal(fn(), expected) + suite.add(name, { minTime: 0.05, maxTime: seconds }, () => { length ^= fn().length }) + } + return suite.run() +} diff --git a/benchmark.js b/benchmark.js index f60a2b1..b76eb78 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,7 +1,6 @@ 'use strict' -const Benchmark = require('bench-node') -const suite = new Benchmark.Suite() +const runBenchmarks = require('./benchmark-utils') const stringify = require('.').configure({ deterministic: true }) const array = Array.from({ length: 10 }, (_, i) => i) @@ -21,88 +20,19 @@ deepCirc.deep.deep.circ = deepCirc deepCirc.deep.circ = deepCirc deepCirc.array = array -// One arg "simple" -suite.add('simple: simple object', function () { - stringify(obj) -}) -suite.add('simple: circular ', function () { - stringify(circ) -}) -suite.add('simple: deep ', function () { - stringify(deep) -}) -suite.add('simple: deep circular', function () { - stringify(deepCirc) -}) - -// Two args "replacer" -suite.add('\nreplacer: simple object', function () { - stringify(obj, (_, v) => v) -}) -suite.add('replacer: circular ', function () { - stringify(circ, (_, v) => v) -}) -suite.add('replacer: deep ', function () { - stringify(deep, (_, v) => v) -}) -suite.add('replacer: deep circular', function () { - stringify(deepCirc, (_, v) => v) -}) - -// Two args "array" -suite.add('\narray: simple object', function () { - stringify(obj, ['array']) -}) -suite.add('array: circular ', function () { - stringify(circ, ['array']) -}) -suite.add('array: deep ', function () { - stringify(deep, ['array']) -}) -suite.add('array: deep circular', function () { - stringify(deepCirc, ['array']) -}) - -// Three args "full replacer" -suite.add('\nfull replacer: simple object', function () { - stringify(obj, (_, v) => v, 2) -}) -suite.add('full replacer: circular ', function () { - stringify(circ, (_, v) => v, 2) -}) -suite.add('full replacer: deep ', function () { - stringify(deep, (_, v) => v, 2) -}) -suite.add('full replacer: deep circular', function () { - stringify(deepCirc, (_, v) => v, 2) -}) - -// Three args "full array" -suite.add('\nfull array: simple object', function () { - stringify(obj, ['array'], 2) -}) -suite.add('full array: circular ', function () { - stringify(circ, ['array'], 2) -}) -suite.add('full array: deep ', function () { - stringify(deep, ['array'], 2) -}) -suite.add('full array: deep circular', function () { - stringify(deepCirc, ['array'], 2) -}) - -// Three args "indentation only" -suite.add('\nindentation: simple object', function () { - stringify(obj, null, 2) -}) -suite.add('indentation: circular ', function () { - stringify(circ, null, 2) -}) -suite.add('indentation: deep ', function () { - stringify(deep, null, 2) -}) -suite.add('indentation: deep circular', function () { - stringify(deepCirc, null, 2) -}) - -suite.run() +const cases = [] +const replacer = (_, value) => value +for (const [mode, args] of [ + ['simple', []], + ['replacer', [replacer]], + ['array', [['array']]], + ['full replacer', [replacer, 2]], + ['full array', [['array'], 2]], + ['indentation', [null, 2]] +]) { + for (const [name, value] of [['simple object', obj], ['circular', circ], ['deep', deep], ['deep circular', deepCirc]]) { + cases.push([`${mode}: ${name}`, () => stringify(value, ...args)]) + } +} + +runBenchmarks(cases) diff --git a/compare.js b/compare.js index 6e7404f..a0af3e7 100644 --- a/compare.js +++ b/compare.js @@ -1,7 +1,8 @@ 'use strict' -const Benchmark = require('bench-node') -const suite = new Benchmark.Suite() +const assert = require('node:assert/strict') + +const runBenchmarks = require('./benchmark-utils') const testData = require('./test.json') const stringifyPackages = { @@ -16,19 +17,71 @@ const stringifyPackages = { 'safe-stable-stringify': require('.') } -for (const name in stringifyPackages) { - let fn - if (typeof stringifyPackages[name] === 'function') { - fn = stringifyPackages[name] - } else if (typeof stringifyPackages[name] === 'string') { - fn = require(name)[stringifyPackages[name]] - } else { - fn = require(name) +for (const name of Object.keys(stringifyPackages)) { + if (typeof stringifyPackages[name] === 'string') { + stringifyPackages[name] = require(name)[stringifyPackages[name]] + } else if (stringifyPackages[name] === true) { + stringifyPackages[name] = require(name) } +} + +const telemetry = { + device: { id: 'weather-station-17', firmware: '3.2.1', online: true }, + recordedAt: new Date('2026-01-15T12:30:00.000Z'), + payload: Uint8Array.from({ length: 128 }, (_, index) => (index * 17) % 256), + histogram: Uint32Array.from({ length: 32 }, (_, index) => index * index * 13), + temperatures: Float32Array.from({ length: 24 }, (_, index) => 18 + index / 8), + pressure: new Float64Array([1013.25, 1013.5, NaN, 1013.75]), + signature: Buffer.from('e7c93f142ae4907e8fae3cb589e0f87a', 'hex'), + location: { latitude: 52.52, longitude: 13.405 }, + alarms: [false, true, false] +} +const counters = { + ...telemetry, + sequence: 18446744073709551001n, + timestampNs: 1768480200000000000n, + totals: new BigUint64Array([9007199254740993n, 18446744073709551001n]), + deltas: new BigInt64Array([-9007199254740993n, 33n, 0n]) +} +const bigintReplacer = (_, value) => typeof value === 'bigint' ? String(value) : value +const bigintOptions = { replacer: bigintReplacer } +const deterministicOptions = { replacer: bigintReplacer, cycles: true } +const bigintPackages = { + 'json-stable-stringify': value => stringifyPackages['json-stable-stringify'](value, bigintOptions), + 'json-stringify-deterministic': value => stringifyPackages['json-stringify-deterministic'](value, deterministicOptions), + 'fast-safe-stringify': value => stringifyPackages['fast-safe-stringify'](value, bigintReplacer), + 'safe-stable-stringify': require('.').configure({ bigint: 'string' }) +} + +async function compare (title, value, implementations) { + console.log(title) + const expected = JSON.parse(JSON.stringify(value, bigintReplacer)) + const cases = [] + for (const name of Object.keys(stringifyPackages)) { + const fn = implementations[name] + if (fn === undefined) { + let output + try { + output = stringifyPackages[name](value) + } catch (error) { + assert.ok(error instanceof TypeError) + } + if (output !== undefined) assert.notDeepEqual(JSON.parse(output), expected) + console.log(`${name}: BigInt is not supported`) + continue + } + const output = fn(value) + assert.deepEqual(JSON.parse(output), expected) + if (value === testData) assert.equal(output, require('.')(value)) + cases.push([name, () => fn(value)]) + } + await runBenchmarks(cases, 2) +} - suite.add(name, function () { - fn(testData) - }) +async function main () { + await compare('JSON-compatible object', testData, stringifyPackages) + await compare('Typed arrays, Date and Buffer', telemetry, stringifyPackages) + await compare('Typed arrays, Date, Buffer and BigInt (decimal strings)', counters, bigintPackages) } -suite.run() +main() diff --git a/index.js b/index.js index e0aa013..f33f482 100644 --- a/index.js +++ b/index.js @@ -6,12 +6,14 @@ const { hasOwnProperty } = Object.prototype // eslint-disable-next-line no-control-regex const strEscapeSequencesRegExp = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ +const nodeVersion = typeof process === 'object' ? process?.versions?.node : undefined +const strEscapeLength = Number(nodeVersion?.slice(0, nodeVersion.indexOf('.'))) >= 22 ? 128 : 5000 // Escape C0 control characters, double quotes, the backslash and every code // unit with a numeric value in the inclusive range 0xD800 to 0xDFFF. function strEscape (str) { - // Some magic numbers that worked out fine while benchmarking with v8 8.0 - if (str.length < 5000 && !strEscapeSequencesRegExp.test(str)) { + // Native escaping is faster for longer strings on Node.js 22 and later. + if (str.length < strEscapeLength && !strEscapeSequencesRegExp.test(str)) { return `"${str}"` } return JSON.stringify(str) @@ -28,7 +30,16 @@ function sort (array, comparator) { return array.sort(comparator) } let moves = 0 - for (let i = 1; i < array.length; i++) { + if (array.length > 1 && array[0] > array[1]) { + const currentValue = array[1] + if (array.length > 32 && currentValue > array[array.length - 1]) { + return array.sort() + } + array[1] = array[0] + array[0] = currentValue + moves = 1 + } + for (let i = 2; i < array.length; i++) { const currentValue = array[i] if (array[i - 1] <= currentValue) continue let position = i @@ -280,322 +291,294 @@ function configure (options) { } value = replacer.call(parent, key, value) - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } + if (typeof value === 'object') { + if (value === null) { + return 'null' + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue + } - let res = '' - let join = ',' - const originalIndentation = indentation + let res = '' + let join = ',' + const originalIndentation = indentation - if (Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - if (spacer !== '') { - indentation += spacer - res += `\n${indentation}` - join = `,\n${indentation}` - } - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - res += join - } - const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `${join}"... ${getItemCount(removedKeys)} not stringified"` - } - if (spacer !== '') { - res += `\n${originalIndentation}` - } - stack.pop() - return `[${res}]` - } - - let keys = Object.keys(value) - const keyLength = keys.length - if (keyLength === 0) { - return '{}' + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' } if (maximumDepth < stack.length + 1) { - return '"[Object]"' + return '"[Array]"' } - let whitespace = '' - let separator = '' + stack.push(value) if (spacer !== '') { indentation += spacer + res += `\n${indentation}` join = `,\n${indentation}` - whitespace = ' ' - } - const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (deterministic && getTypedArrayType(value) === undefined) { - keys = sort(keys, comparator) } - stack.push(value) - for (let i = 0; i < maximumPropertiesToStringify; i++) { - const key = keys[i] - const tmp = stringifyFnReplacer(key, value, stack, replacer, spacer, indentation) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}:${whitespace}${tmp}` - separator = join - } + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) + res += tmp !== undefined ? tmp : 'null' + res += join } - if (keyLength > maximumBreadth) { - const removedKeys = keyLength - maximumBreadth - res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"` - separator = join + const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `${join}"... ${getItemCount(removedKeys)} not stringified"` } - if (spacer !== '' && separator.length > 1) { - res = `\n${indentation}${res}\n${originalIndentation}` + if (spacer !== '') { + res += `\n${originalIndentation}` } stack.pop() - return `{${res}}` + return `[${res}]` } - case 'number': - return isFinite(value) ? String(value) : fail ? fail(value) : 'null' - case 'boolean': - return value === true ? 'true' : 'false' - case 'undefined': - return undefined - case 'bigint': - if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) + + let keys = Object.keys(value) + const keyLength = keys.length + if (keyLength === 0) { + return '{}' + } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + let whitespace = '' + let separator = '' + if (spacer !== '') { + indentation += spacer + join = `,\n${indentation}` + whitespace = ' ' + } + const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) + if (deterministic && getTypedArrayType(value) === undefined) { + keys = sort(keys, comparator) + } + stack.push(value) + for (let i = 0; i < maximumPropertiesToStringify; i++) { + const key = keys[i] + const tmp = stringifyFnReplacer(key, value, stack, replacer, spacer, indentation) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}:${whitespace}${tmp}` + separator = join } - // fallthrough - default: - return fail ? fail(value) : undefined + } + if (keyLength > maximumBreadth) { + const removedKeys = keyLength - maximumBreadth + res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"` + separator = join + } + if (spacer !== '' && separator.length > 1) { + res = `\n${indentation}${res}\n${originalIndentation}` + } + stack.pop() + return `{${res}}` } + return stringifyFnPrimitive(value) } /** * @param {string | number} key - * @param {unknown} value + * @param {object | null} value * @param {object[]} stack * @param {Set} replacer * @param {string} spacer * @param {string} indentation */ let stringifyArrayReplacer = function (key, value, stack, replacer, spacer, indentation) { - if (typeof value === 'object' && value !== null && typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (value === null) { + return 'null' + } + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { if (typeof key === 'number') key = String(key) - value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + if (typeof jsonValue !== 'object') { + return stringifyPrimitive(jsonValue) + } + if (jsonValue === null) { + return 'null' + } + value = jsonValue + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue } - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } - - const originalIndentation = indentation - let res = '' - let join = ',' + const originalIndentation = indentation + let res = '' + let join = ',' - if (Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - if (spacer !== '') { - indentation += spacer - res += `\n${indentation}` - join = `,\n${indentation}` - } - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyArrayReplacer(i, value[i], stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - res += join - } - const tmp = stringifyArrayReplacer(i, value[i], stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `${join}"... ${getItemCount(removedKeys)} not stringified"` - } - if (spacer !== '') { - res += `\n${originalIndentation}` - } - stack.pop() - return `[${res}]` - } - if (maximumDepth < stack.length + 1) { - return '"[Object]"' - } - stack.push(value) - let whitespace = '' - if (spacer !== '') { - indentation += spacer - join = `,\n${indentation}` - whitespace = ' ' - } - let separator = '' - for (const key of replacer) { - const tmp = stringifyArrayReplacer(key, /** @type {Record} */ (value)[key], stack, replacer, spacer, indentation) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}:${whitespace}${tmp}` - separator = join - } - } - if (spacer !== '' && separator.length > 1) { - res = `\n${indentation}${res}\n${originalIndentation}` - } - stack.pop() - return `{${res}}` + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' } - case 'number': - return isFinite(value) ? String(value) : fail ? fail(value) : 'null' - case 'boolean': - return value === true ? 'true' : 'false' - case 'undefined': - return undefined - case 'bigint': - if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) - } - // fallthrough - default: - return fail ? fail(value) : undefined + if (maximumDepth < stack.length + 1) { + return '"[Array]"' + } + stack.push(value) + if (spacer !== '') { + indentation += spacer + res += `\n${indentation}` + join = `,\n${indentation}` + } + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const item = value[i] + const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + res += join + } + const item = value[i] + const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `${join}"... ${getItemCount(removedKeys)} not stringified"` + } + if (spacer !== '') { + res += `\n${originalIndentation}` + } + stack.pop() + return `[${res}]` } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + stack.push(value) + let whitespace = '' + if (spacer !== '') { + indentation += spacer + join = `,\n${indentation}` + whitespace = ' ' + } + let separator = '' + for (const key of replacer) { + const item = /** @type {Record} */ (value)[key] + const tmp = typeof item === 'object' ? stringifyArrayReplacer(key, item, stack, replacer, spacer, indentation) : stringifyPrimitive(item) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}:${whitespace}${tmp}` + separator = join + } + } + if (spacer !== '' && separator.length > 1) { + res = `\n${indentation}${res}\n${originalIndentation}` + } + stack.pop() + return `{${res}}` } /** * @param {string | number} key - * @param {unknown} value + * @param {object | null} value * @param {object[]} stack * @param {string} spacer * @param {string} indentation */ let stringifyIndent = function (key, value, stack, spacer, indentation) { - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { - if (typeof key === 'number') key = String(key) - value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) - // Prevent calling `toJSON` again. - if (typeof value !== 'object') { - return stringifyIndent(key, value, stack, spacer, indentation) - } - if (value === null) { - return 'null' - } - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } - const originalIndentation = indentation + if (value === null) { + return 'null' + } + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + if (typeof jsonValue !== 'object') { + return stringifyPrimitive(jsonValue) + } + if (jsonValue === null) { + return 'null' + } + value = jsonValue + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue + } + const originalIndentation = indentation - if (Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - indentation += spacer - let res = `\n${indentation}` - const join = `,\n${indentation}` - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyIndent(i, value[i], stack, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - res += join - } - const tmp = stringifyIndent(i, value[i], stack, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `${join}"... ${getItemCount(removedKeys)} not stringified"` - } - res += `\n${originalIndentation}` - stack.pop() - return `[${res}]` - } + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' + } + if (maximumDepth < stack.length + 1) { + return '"[Array]"' + } + stack.push(value) + indentation += spacer + let res = `\n${indentation}` + const join = `,\n${indentation}` + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const item = value[i] + const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + res += join + } + const item = value[i] + const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `${join}"... ${getItemCount(removedKeys)} not stringified"` + } + res += `\n${originalIndentation}` + stack.pop() + return `[${res}]` + } - let keys = Object.keys(value) - const keyLength = keys.length - if (keyLength === 0) { - return '{}' - } - if (maximumDepth < stack.length + 1) { - return '"[Object]"' - } - indentation += spacer - const join = `,\n${indentation}` - let res = '' - let separator = '' - let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - const typedArrayType = getTypedArrayType(value) - if (typedArrayType !== undefined) { - res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, fail, typedArrayType) - keys = keys.slice(/** @type {{length: number}} */ (value).length) - maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length - separator = join - } - if (deterministic) { - keys = sort(keys, comparator) - } - stack.push(value) - for (let i = 0; i < maximumPropertiesToStringify; i++) { - const key = keys[i] - const tmp = stringifyIndent(key, /** @type {Record} */ (value)[key], stack, spacer, indentation) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}: ${tmp}` - separator = join - } - } - if (keyLength > maximumBreadth) { - const removedKeys = keyLength - maximumBreadth - res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"` - separator = join - } - if (separator !== '') { - res = `\n${indentation}${res}\n${originalIndentation}` - } - stack.pop() - return `{${res}}` + let keys = Object.keys(value) + const keyLength = keys.length + if (keyLength === 0) { + return '{}' + } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + indentation += spacer + const join = `,\n${indentation}` + let res = '' + let separator = '' + let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) + const typedArrayType = getTypedArrayType(value) + if (typedArrayType !== undefined) { + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, fail, typedArrayType) + keys = keys.slice(/** @type {{length: number}} */ (value).length) + maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length + separator = join + } + if (deterministic) { + keys = sort(keys, comparator) + } + stack.push(value) + for (let i = 0; i < maximumPropertiesToStringify; i++) { + const key = keys[i] + const item = /** @type {Record} */ (value)[key] + const tmp = typeof item === 'object' ? stringifyIndent(key, item, stack, spacer, indentation) : stringifyPrimitive(item) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}: ${tmp}` + separator = join } + } + if (keyLength > maximumBreadth) { + const removedKeys = keyLength - maximumBreadth + res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"` + separator = join + } + if (separator !== '') { + res = `\n${indentation}${res}\n${originalIndentation}` + } + stack.pop() + return `{${res}}` + } + + let stringifyPrimitive = function (value) { + switch (typeof value) { + case 'string': + return strEscape(value) case 'number': return isFinite(value) ? String(value) : fail ? fail(value) : 'null' case 'boolean': @@ -604,10 +587,7 @@ function configure (options) { return undefined case 'bigint': if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) + return bigint === 'string' ? `"${value}"` : String(value) } // fallthrough default: @@ -615,123 +595,108 @@ function configure (options) { } } - /** - * @param {string | number} key - * @param {unknown} value - * @param {object[]} stack - */ - let stringifySimple = function (key, value, stack) { - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { - if (typeof key === 'number') key = String(key) - value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) - // Prevent calling `toJSON` again - if (typeof value !== 'object') { - return stringifySimple(key, value, stack) - } - if (value === null) { - return 'null' - } - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } + let stringifyObject = function (key, value, stack) { + if (value === null) { + return 'null' + } + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + // Prevent calling `toJSON` again + if (typeof jsonValue !== 'object') { + return stringifyPrimitive(jsonValue) + } + if (jsonValue === null) { + return 'null' + } + value = jsonValue + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue + } - let res = '' + let res = '' - const hasLength = /** @type {{length?: unknown}} */ (value).length !== undefined - if (hasLength && Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifySimple(i, value[i], stack) - res += tmp !== undefined ? tmp : 'null' - res += ',' - } - const tmp = stringifySimple(i, value[i], stack) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `,"... ${getItemCount(removedKeys)} not stringified"` - } - stack.pop() - return `[${res}]` - } + const hasLength = /** @type {{length?: unknown}} */ (value).length !== undefined + if (hasLength && Array.isArray(value)) { + if (value.length === 0) { + return '[]' + } + if (maximumDepth < stack.length + 1) { + return '"[Array]"' + } + stack.push(value) + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const item = value[i] + const tmp = typeof item === 'object' + ? stringifyObject(i, item, stack) + : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + res += ',' + } + const item = value[i] + const tmp = typeof item === 'object' + ? stringifyObject(i, item, stack) + : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `,"... ${getItemCount(removedKeys)} not stringified"` + } + stack.pop() + return `[${res}]` + } - let keys = Object.keys(value) - const keyLength = keys.length - if (keyLength === 0) { - return '{}' - } - if (maximumDepth < stack.length + 1) { - return '"[Object]"' - } - let separator = '' - let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - const typedArrayType = hasLength ? getTypedArrayType(value) : undefined - if (typedArrayType !== undefined) { - res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType) - keys = keys.slice(/** @type {{length: number}} */ (value).length) - maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length - separator = ',' - } - if (deterministic) { - keys = sort(keys, comparator) - } - stack.push(value) - for (let i = 0; i < maximumPropertiesToStringify; i++) { - const key = keys[i] - const tmp = stringifySimple(key, /** @type {Record} */ (value)[key], stack) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}:${tmp}` - separator = ',' - } - } - if (keyLength > maximumBreadth) { - const removedKeys = keyLength - maximumBreadth - res += `${separator}"...":"${getItemCount(removedKeys)} not stringified"` - } - stack.pop() - return `{${res}}` + let keys = Object.keys(value) + const keyLength = keys.length + if (keyLength === 0) { + return '{}' + } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + let separator = '' + let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) + const typedArrayType = hasLength ? getTypedArrayType(value) : undefined + if (typedArrayType !== undefined) { + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType) + keys = keys.slice(/** @type {{length: number}} */ (value).length) + maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length + separator = ',' + } + if (deterministic) { + keys = sort(keys, comparator) + } + stack.push(value) + for (let i = 0; i < maximumPropertiesToStringify; i++) { + const key = keys[i] + const item = /** @type {Record} */ (value)[key] + const tmp = typeof item === 'object' + ? stringifyObject(key, item, stack) + : stringifyPrimitive(item) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}:${tmp}` + separator = ',' } - case 'number': - return isFinite(value) ? String(value) : fail ? fail(value) : 'null' - case 'boolean': - return value === true ? 'true' : 'false' - case 'undefined': - return undefined - case 'bigint': - if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) - } - // fallthrough - default: - return fail ? fail(value) : undefined } + if (keyLength > maximumBreadth) { + const removedKeys = keyLength - maximumBreadth + res += `${separator}"...":"${getItemCount(removedKeys)} not stringified"` + } + stack.pop() + return `{${res}}` } + const stringifyFnPrimitive = stringifyPrimitive if (safe) { stringifyFnReplacer = makeSafe(stringifyFnReplacer) stringifyArrayReplacer = makeSafe(stringifyArrayReplacer) stringifyIndent = makeSafe(stringifyIndent) - stringifySimple = makeSafe(stringifySimple) + stringifyObject = makeSafe(stringifyObject) + stringifyPrimitive = makeSafe(stringifyPrimitive) } function stringify (value, replacer, space) { @@ -750,14 +715,20 @@ function configure (options) { return stringifyFnReplacer('', { '': value }, [], replacer, spacer, '') } if (Array.isArray(replacer)) { - return stringifyArrayReplacer('', value, [], getUniqueReplacerSet(replacer), spacer, '') + return typeof value === 'object' + ? stringifyArrayReplacer('', value, [], getUniqueReplacerSet(replacer), spacer, '') + : stringifyPrimitive(value) } } if (spacer.length !== 0) { - return stringifyIndent('', value, [], spacer, '') + return typeof value === 'object' + ? stringifyIndent('', value, [], spacer, '') + : stringifyPrimitive(value) } } - return stringifySimple('', value, []) + return typeof value === 'object' + ? stringifyObject('', value, []) + : stringifyPrimitive(value) } return stringify diff --git a/package.json b/package.json index d38c6f2..5230228 100644 --- a/package.json +++ b/package.json @@ -35,27 +35,30 @@ "engines": { "node": ">=18" }, + "overrides": { + "@tapjs/processinfo": "3.1.9" + }, "author": "Ruben Bridgewater", "license": "MIT", "typings": "index.d.ts", "devDependencies": { "@types/json-stable-stringify": "^1.2.0", - "@types/node": "^18.19.130", + "@types/node": "^26.6.5", "clone": "^2.1.2", - "esbuild": "^0.21.5", + "esbuild": "^0.28.2", "fast-json-stable-stringify": "^2.1.0", "fast-safe-stringify": "^2.1.1", "fast-stable-stringify": "^1.0.0", "faster-stable-stringify": "^1.0.0", "fastest-stable-stringify": "^2.0.2", "json-stable-stringify": "^1.3.0", - "json-stringify-deterministic": "^1.0.12", + "json-stringify-deterministic": "^1.0.18", "json-stringify-safe": "^5.0.1", - "rollup": "^3.30.0", - "bench-node": "^0.14.0", + "rollup": "^4.64.6", + "bench-node": "^0.15.0", "standard": "^17.1.2", - "tap": "^16.3.10", - "typescript": "^5.9.3" + "tap": "^20.0.3", + "typescript": "^6.0.3" }, "repository": { "type": "git", diff --git a/readme.md b/readme.md index 0a1119e..21472ef 100644 --- a/readme.md +++ b/readme.md @@ -131,48 +131,84 @@ with `JSON.stringify()`. ## Performance / Benchmarks -Currently this is by far the fastest known stable (deterministic) stringify -implementation. This is especially important for big objects and TypedArrays. - -(Dell Precision 5540, i7-9850H CPU @ 2.60GHz, Node.js 16.11.1) - -```md -simple: simple object x 3,463,894 ops/sec ±0.44% (98 runs sampled) -simple: circular x 1,236,007 ops/sec ±0.46% (99 runs sampled) -simple: deep x 18,942 ops/sec ±0.41% (93 runs sampled) -simple: deep circular x 18,690 ops/sec ±0.72% (96 runs sampled) - -replacer: simple object x 2,664,940 ops/sec ±0.31% (98 runs sampled) -replacer: circular x 1,015,981 ops/sec ±0.09% (99 runs sampled) -replacer: deep x 17,328 ops/sec ±0.38% (97 runs sampled) -replacer: deep circular x 17,071 ops/sec ±0.21% (98 runs sampled) - -array: simple object x 3,869,608 ops/sec ±0.22% (98 runs sampled) -array: circular x 3,853,943 ops/sec ±0.45% (96 runs sampled) -array: deep x 3,563,227 ops/sec ±0.20% (100 runs sampled) -array: deep circular x 3,286,475 ops/sec ±0.07% (100 runs sampled) - -indentation: simple object x 2,183,162 ops/sec ±0.66% (97 runs sampled) -indentation: circular x 872,538 ops/sec ±0.57% (98 runs sampled) -indentation: deep x 16,795 ops/sec ±0.48% (93 runs sampled) -indentation: deep circular x 16,443 ops/sec ±0.40% (97 runs sampled) +(Apple M4 Max, macOS 26.6.2, Node.js 26.3.1, V8 14.6.202.34-node.20) + +```text +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +simple: simple object 4,280,054 ops/sec (7 runs, CV 1.21%) +simple: circular 1,867,764 ops/sec (7 runs, CV 0.90%) +simple: deep 31,783 ops/sec (7 runs, CV 1.85%) +simple: deep circular 31,095 ops/sec (7 runs, CV 1.07%) +replacer: simple object 2,847,302 ops/sec (7 runs, CV 1.13%) +replacer: circular 1,264,209 ops/sec (7 runs, CV 0.47%) +replacer: deep 29,244 ops/sec (7 runs, CV 0.77%) +replacer: deep circular 28,500 ops/sec (7 runs, CV 0.76%) +array: simple object 4,433,315 ops/sec (7 runs, CV 1.20%) +array: circular 4,400,683 ops/sec (7 runs, CV 0.82%) +array: deep 4,410,383 ops/sec (7 runs, CV 0.50%) +array: deep circular 4,435,182 ops/sec (7 runs, CV 0.52%) +full replacer: simple object 2,675,568 ops/sec (7 runs, CV 0.81%) +full replacer: circular 1,226,915 ops/sec (7 runs, CV 0.75%) +full replacer: deep 27,826 ops/sec (7 runs, CV 0.99%) +full replacer: deep circular 27,239 ops/sec (7 runs, CV 1.96%) +full array: simple object 3,781,217 ops/sec (7 runs, CV 0.95%) +full array: circular 3,771,576 ops/sec (7 runs, CV 1.36%) +full array: deep 3,781,851 ops/sec (7 runs, CV 1.60%) +full array: deep circular 3,835,795 ops/sec (7 runs, CV 0.93%) +indentation: simple object 3,971,352 ops/sec (7 runs, CV 1.33%) +indentation: circular 1,810,550 ops/sec (7 runs, CV 1.06%) +indentation: deep 31,776 ops/sec (7 runs, CV 1.20%) +indentation: deep circular 31,396 ops/sec (7 runs, CV 1.41%) ``` Comparing `safe-stable-stringify` with known alternatives: -```md -fast-json-stable-stringify x 18,765 ops/sec ±0.71% (94 runs sampled) -json-stable-stringify x 13,870 ops/sec ±0.72% (94 runs sampled) -fast-stable-stringify x 21,343 ops/sec ±0.33% (95 runs sampled) -faster-stable-stringify x 17,707 ops/sec ±0.44% (97 runs sampled) -json-stringify-deterministic x 11,208 ops/sec ±0.57% (98 runs sampled) -fast-safe-stringify x 21,460 ops/sec ±0.75% (99 runs sampled) -this x 30,367 ops/sec ±0.39% (96 runs sampled) +```text +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +fastest-stable-stringify 73,060 ops/sec (7 runs, CV 0.30%) +fast-json-stable-stringify 66,953 ops/sec (7 runs, CV 0.87%) +json-stable-stringify 50,656 ops/sec (7 runs, CV 0.48%) +fast-stable-stringify 72,668 ops/sec (7 runs, CV 0.94%) +faster-stable-stringify 64,879 ops/sec (7 runs, CV 0.80%) +json-stringify-deterministic 45,529 ops/sec (7 runs, CV 0.72%) +fast-safe-stringify 59,107 ops/sec (7 runs, CV 0.55%) +safe-stable-stringify 75,892 ops/sec (7 runs, CV 0.48%) +``` + +The `fast-safe-stringify` comparison uses its stable implementation. + +### Typed arrays -The fastest is this +Serializing a telemetry record with binary sensor data, a Date, and a Buffer: + +```text +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +fastest-stable-stringify 69,502 ops/sec (7 runs, CV 0.73%) +fast-json-stable-stringify 63,260 ops/sec (7 runs, CV 0.31%) +json-stable-stringify 46,499 ops/sec (7 runs, CV 0.75%) +fast-stable-stringify 116,091 ops/sec (7 runs, CV 0.68%) +faster-stable-stringify 62,078 ops/sec (7 runs, CV 0.65%) +json-stringify-deterministic 33,567 ops/sec (7 runs, CV 0.57%) +fast-safe-stringify 56,618 ops/sec (7 runs, CV 0.57%) +safe-stable-stringify 126,114 ops/sec (7 runs, CV 0.48%) ``` -The `fast-safe-stringify` comparison uses the modules stable implementation. +### Typed arrays and BigInt + +The same record with 64-bit counters. BigInt values serialize as decimal strings +through each library's public options. + +```text +fastest-stable-stringify: BigInt is not supported +fast-json-stable-stringify: BigInt is not supported +fast-stable-stringify: BigInt is not supported +faster-stable-stringify: BigInt is not supported +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +json-stable-stringify 39,849 ops/sec (7 runs, CV 1.23%) +json-stringify-deterministic 43,579 ops/sec (7 runs, CV 0.46%) +fast-safe-stringify 42,583 ops/sec (7 runs, CV 0.85%) +safe-stable-stringify 113,681 ops/sec (7 runs, CV 0.87%) +``` ## Acknowledgements diff --git a/test-modules.js b/test-modules.js index 0956a69..16e356c 100644 --- a/test-modules.js +++ b/test-modules.js @@ -5,6 +5,7 @@ const { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync const { tmpdir } = require('node:os') const { join } = require('node:path') const { promisify } = require('node:util') +const { runInNewContext } = require('node:vm') const { build } = require('esbuild') const { rollup } = require('rollup') @@ -13,6 +14,12 @@ const { test } = require('tap') const execFileAsync = promisify(execFile) const { version } = require('./package.json') +function runNode (args, options) { + // TAP's process tracker adds a deprecated loader to ESM consumers on Node 26. + const nodeArgs = Number(process.versions.node.split('.')[0]) >= 26 ? ['--disable-warning=DEP0205', ...args] : args + return execFileAsync(process.execPath, nodeArgs, options) +} + /** * @param {string} directory * @param {string} packageVersion @@ -61,8 +68,8 @@ async function testModuleEntryPoints (assert) { } const results = await Promise.all([ - execFileAsync(process.execPath, ['modules.cjs', 'require-first'], { cwd: directory }), - execFileAsync(process.execPath, ['modules.cjs', 'import-first'], { cwd: directory }) + runNode(['modules.cjs', 'require-first'], { cwd: directory }), + runNode(['modules.cjs', 'import-first'], { cwd: directory }) ]) for (const { stdout, stderr } of results) { assert.equal(stdout, '', 'no output') @@ -114,11 +121,12 @@ async function testBundlers (assert) { await bundle.close() } const esmSource = readFileSync(join(directory, 'consumer.mjs'), 'utf8') + runInNewContext(readFileSync(join(directory, 'esbuild.cjs'), 'utf8')) writeFileSync(join(directory, 'rollup-consumer.mjs'), esmSource.replace('safe-stable-stringify', './rollup.mjs')) const results = await Promise.all([ - execFileAsync(process.execPath, [join(directory, 'esbuild.mjs')]), - execFileAsync(process.execPath, [join(directory, 'esbuild.cjs')]), - execFileAsync(process.execPath, [join(directory, 'rollup-consumer.mjs')]) + runNode([join(directory, 'esbuild.mjs')]), + runNode([join(directory, 'esbuild.cjs')]), + runNode([join(directory, 'rollup-consumer.mjs')]) ]) for (const { stdout, stderr } of results) { assert.equal(stdout, '', 'bundle produces no output') @@ -137,12 +145,13 @@ async function testTypes (assert) { ['CommonJS', 'Node', 'types.cts', 'types.mts'], ['ESNext', 'Bundler', 'types.mts'] ]) { - tasks.push(execFileAsync(process.execPath, [ + tasks.push(runNode([ require.resolve('typescript/bin/tsc'), '--noEmit', '--strict', '--module', module, '--moduleResolution', resolution, + '--ignoreDeprecations', '6.0', '--target', 'ES2020', ...files ], { cwd: directory })) diff --git a/test.js b/test.js index 9cc06bc..8242f5e 100644 --- a/test.js +++ b/test.js @@ -4,6 +4,57 @@ const clone = require('clone') const { constants: { MAX_STRING_LENGTH } } = require('buffer') const { runInNewContext } = require('vm') +test('escaping preserves output across runtime and string length boundaries', (assert) => { + const paths = [require.resolve('./index.js'), require.resolve('.')] + const cached = paths.map(path => require.cache[path]) + const nativeProcess = globalThis.process + const exportSymbol = Symbol.for('safe-stable-stringify@3.0.0') + const descriptor = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(globalThis, exportSymbol)) + for (const process of [undefined, null, {}, { versions: {} }, { versions: { node: '21.0.0' } }, { versions: { node: '22.0.0' } }, { versions: { node: '26.3.1' } }]) { + let serializer + try { + // @ts-expect-error + globalThis.process = process + for (const path of paths) delete require.cache[path] + delete globalThis[exportSymbol] + serializer = require('.') + } finally { + globalThis.process = nativeProcess + Object.defineProperty(globalThis, exportSymbol, descriptor) + for (let i = 0; i < paths.length; i++) require.cache[paths[i]] = cached[i] + } + for (const length of [0, 31, 32, 127, 128, 4999, 5000]) { + const original = JSON.stringify + let calls = 0 + try { + JSON.stringify = value => { + calls++ + return original(value) + } + const value = 'x'.repeat(length) + const output = serializer(value) + const threshold = Number.parseInt(process?.versions?.node ?? '') >= 22 ? 128 : 5000 + assert.equal(calls, length >= threshold ? 1 : 0, 'native escaping follows the runtime threshold') + assert.equal(output, original(value)) + } finally { + JSON.stringify = original + } + for (const ending of ['', '"', '\\', '\n', '\ud800', '\udfff', '\ud83d\ude00', '\u0100']) { + const value = 'x'.repeat(Math.max(0, length - ending.length)) + ending + for (const input of [value, [value], { [value]: value }]) { + for (const replacer of [null, (_, value) => value, [value]]) { + for (const space of [undefined, 2]) { + // @ts-expect-error + assert.equal(serializer(input, replacer, space), JSON.stringify(input, replacer, space)) + } + } + } + } + } + } + assert.end() +}) + for (const name of ['Float16Array', 'Float32Array', 'Float64Array']) { const TypedArray = globalThis[/** @type {'Float16Array' | 'Float32Array' | 'Float64Array'} */ (name)] test(`${name} serializes non-finite values`, { skip: TypedArray === undefined }, @@ -261,6 +312,123 @@ test('toJSON receives array keys as string', /** @param {import('tap').Test} ass assert.end() }) +test('primitive values preserve their formatting in every mode', (assert) => { + const values = [0, -0, 1.5, NaN, Infinity, -Infinity, '', 'text', '\n"\\', '\ud800', null, undefined, true, false, Symbol('value'), () => {}, { value: 42 }] + for (const safe of [false, true]) { + const serializer = stringify.configure({ safe }) + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + for (const value of values) { + for (const input of [value, [value, value], [value], { value }]) { + assert.equal(serializer(input, replacer, space), JSON.stringify(input, null, space)) + } + const wrapped = { toJSON () { return value } } + assert.equal(serializer([wrapped, wrapped], replacer, space), JSON.stringify([wrapped, wrapped], null, space)) + } + assert.equal(serializer(new Array(2), replacer, space), JSON.stringify([null, null], null, space)) + } + } + } + assert.end() +}) + +test('primitive escaping failures preserve adjacent elements in every mode', (assert) => { + const serializer = stringify.configure({ safe: true }) + const value = '\n' + const nativeStringify = JSON.stringify + const message = 'Error: Stringification failed (Invalid string length)' + JSON.stringify = (input, replacer, space) => { + if (input === value) throw new RangeError('Invalid string length') + return nativeStringify(input, replacer, space) + } + try { + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + assert.equal(serializer([value], replacer, space), nativeStringify([message], null, space)) + assert.equal(serializer([value, 0, '\t'], replacer, space), nativeStringify([message, 0, '\t'], null, space)) + assert.equal(serializer(['\t', 0, value], replacer, space), nativeStringify(['\t', 0, message], null, space)) + } + } + } finally { + JSON.stringify = nativeStringify + } + assert.end() +}) + +test('primitive failures and bigint options retain their behavior in every mode', (assert) => { + const unsupported = /** @type {[number | symbol | (() => void), string][]} */ ([ + [NaN, 'number (NaN)'], + [Infinity, 'number (Infinity)'], + [-Infinity, 'number (-Infinity)'], + [Symbol('value'), 'symbol (Symbol(value))'], + [() => {}, 'function'] + ]) + const strict = stringify.configure({ strict: true }) + const safeStrict = stringify.configure({ strict: true, safe: true }) + const serializer = stringify.configure({ safe: true }) + const failing = { get value () { throw new Error('Oops') } } + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + for (const [value, description] of unsupported) { + assert.throws(() => strict([value, 0], replacer, space), /Object can not safely be stringified/) + assert.throws(() => strict([0, value], replacer, space), /Object can not safely be stringified/) + const message = `Error: Stringification failed (Object can not safely be stringified. Received type ${description})` + assert.equal(safeStrict([value, 0], replacer, space), JSON.stringify([message, 0], null, space)) + assert.equal(safeStrict([0, value], replacer, space), JSON.stringify([0, message], null, space)) + } + assert.equal(safeStrict([undefined, undefined], replacer, space), JSON.stringify([null, null], null, space)) + for (const bigint of /** @type {const} */ ([true, false, 'string'])) { + for (const safe of [false, true]) { + for (const strict of [false, true]) { + const serializer = stringify.configure({ bigint, safe, strict }) + if (strict && !bigint && !safe) { + assert.throws(() => serializer([1n, 0], replacer, space), /Received type bigint \(1\)/) + assert.throws(() => serializer([0, 1n], replacer, space), /Received type bigint \(1\)/) + } else { + const expected = bigint === 'string' + ? '1' + : bigint + ? 1 + : strict + ? 'Error: Stringification failed (Object can not safely be stringified. Received type bigint (1))' + : null + assert.equal(serializer([1n, 0], replacer, space), JSON.stringify([expected, 0], null, space)) + assert.equal(serializer([0, 1n], replacer, space), JSON.stringify([0, expected], null, space)) + } + } + } + } + const error = 'Error: Stringification failed (Oops)' + const expected = typeof replacer === 'function' ? { value: error } : error + assert.equal(serializer([failing, 0], replacer, space), JSON.stringify([expected, 0], null, space)) + assert.equal(serializer([0, failing], replacer, space), JSON.stringify([0, expected], null, space)) + } + } + assert.end() +}) + +test('function replacers run after toJSON and can change each input type', (assert) => { + for (const space of [undefined, 2]) { + const calls = [] + const input = [{ toJSON (key) { calls.push(`toJSON:${key}`); return 1 } }, {}, undefined] + /** @this {object} */ + function replacer (key, value) { + calls.push(`replacer:${key}`) + if (key === '0') { + assert.equal(this, input) + assert.equal(value, 1) + return { value } + } + if (key === '1') return false + if (key === '2') return 'filled' + return value + } + assert.equal(stringify(input, replacer, space), JSON.stringify([{ value: 1 }, false, 'filled'], null, space)) + assert.strictSame(calls, ['replacer:', 'toJSON:0', 'replacer:0', 'replacer:value', 'replacer:1', 'replacer:2']) + } + assert.end() +}) + test('circular reference to root', function (assert) { const fixture = { name: 'Tywin Lannister' } fixture.circle = fixture @@ -1612,11 +1780,12 @@ test('deterministic default sorting', function (assert) { }) test('deterministic sorting preserves keys across insertion and native sort boundaries', /** @param {import('tap').Test} assert */ (assert) => { - for (const size of [8, 9, 31, 32, 33, 34, 199, 200, 201]) { + for (const size of [0, 1, 2, 3, 8, 9, 31, 32, 33, 34, 199, 200, 201]) { const keys = Array.from({ length: size }, /** @param {unknown} value @param {number} index */ (value, index) => `key${String(index).padStart(3, '0')}`) const adjacent = keys.slice() - ;[adjacent[1], adjacent[2]] = [adjacent[2], adjacent[1]] - for (const order of [keys, keys.slice().reverse(), adjacent, [keys[size - 1], ...keys.slice(0, -1)]]) { + if (size > 1) [adjacent[0], adjacent[1]] = [adjacent[1], adjacent[0]] + const rotated = size > 0 ? [keys[size - 1], ...keys.slice(0, -1)] : [] + for (const order of [keys, keys.slice().reverse(), adjacent, rotated]) { const value = {} const expected = {} for (const key of order) value[key] = key @@ -1631,6 +1800,42 @@ test('deterministic sorting preserves keys across insertion and native sort boun assert.end() }) +test('array replacers are only processed in the object branch', (assert) => { + for (const options of /** @type {const} */ ([{}, { safe: true }, { strict: true }, { safe: true, strict: true }, { bigint: false }, { bigint: 'string' }])) { + const serialize = stringify.configure(options) + for (const property of ['0', Symbol.iterator]) { + let reads = 0 + const replacer = ['value'] + const error = new Error('replacer read') + Object.defineProperty(replacer, property, { + get () { + reads++ + throw error + } + }) + for (const value of ['value', 0, NaN, Infinity, true, false, undefined, 42n, Symbol('value'), identityReplacer]) { + let expected + try { + expected = serialize(value) + } catch (error) { + assert.throws(() => serialize(value, replacer), /** @type {Error} */ (error)) + continue + } + for (const space of [undefined, 2, '\t']) { + assert.equal(serialize(value, replacer, space), expected) + } + } + assert.equal(reads, 0, 'primitive roots do not read the array replacer') + for (const value of [null, {}, { value: 1 }, []]) { + assert.throws(() => serialize(value, replacer), error) + } + assert.equal(reads, 4, 'the object branch still processes the array replacer') + } + assert.equal(serialize({ value: 1, other: 2 }, ['value', 'value']), '{"value":1}') + } + assert.end() +}) + test('deterministic custom sorting', function (assert) { // Descending const serializer = stringify.configure({ deterministic: (a, b) => b.localeCompare(a) }) diff --git a/tsconfig.json b/tsconfig.json index be233c6..e7f9528 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,10 @@ { "compilerOptions": { - "baseUrl": "./", "allowJs": true, "checkJs": true, "sourceMap": false, "declaration": false, "noEmit": true, - "downlevelIteration": false, "experimentalDecorators": false, "moduleResolution": "nodenext", "importHelpers": false, @@ -20,5 +18,5 @@ ] }, "include": ["**/*.js", "**/*.ts", "**/*.cts", "**/*.mts", "wrapper.cjs", "esm/wrapper.mjs"], - "exclude": ["compare.js", "benchmark.js", "./coverage"] + "exclude": ["compare.js", "benchmark.js", "benchmark-utils.js", "./coverage", "./.tap"] }