From 33f606a66fe44d7acd03ce71baa10f72f638bf80 Mon Sep 17 00:00:00 2001 From: Ferit Date: Sat, 10 Oct 2026 21:04:57 +0300 Subject: [PATCH 1/2] Serialize non-finite typed-array values consistently --- index.js | 10 ++++++---- test.js | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 51e764a..53f6c9e 100644 --- a/index.js +++ b/index.js @@ -70,13 +70,15 @@ function wrapInQuotes (value) { return `"${value}"` } -function stringifyTypedArray (array, separator, maximumBreadth, bigint) { +function stringifyTypedArray (array, separator, maximumBreadth, bigint, fail) { if (array.length < maximumBreadth) { maximumBreadth = array.length } const method = bigint === 'string' && array.length > 0 && typeof array[0] === 'bigint' ? wrapInQuotes - : String + : (value) => typeof value === 'number' && !isFinite(value) + ? fail ? fail(value) : 'null' + : String(value) const whitespace = separator === ',' ? '' : ' ' let res = `"0":${whitespace}${method(array[0])}` for (let i = 1; i < maximumBreadth; i++) { @@ -537,7 +539,7 @@ function configure (options) { let separator = '' let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) if (isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, join, maximumBreadth, bigint) + res += stringifyTypedArray(value, join, maximumBreadth, bigint, fail) keys = keys.slice(value.length) maximumPropertiesToStringify -= value.length separator = join @@ -646,7 +648,7 @@ function configure (options) { let separator = '' let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) if (hasLength && isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, ',', maximumBreadth, bigint) + res += stringifyTypedArray(value, ',', maximumBreadth, bigint, fail) keys = keys.slice(value.length) maximumPropertiesToStringify -= value.length separator = ',' diff --git a/test.js b/test.js index 2bb26a6..fa54c81 100644 --- a/test.js +++ b/test.js @@ -3,6 +3,19 @@ const { stringify } = require('./index.js') const clone = require('clone') const { constants: { MAX_STRING_LENGTH } } = require('buffer') +test('non-finite floating typed arrays produce valid JSON', (assert) => { + for (const TypedArray of [Float32Array, Float64Array]) { + const value = { metrics: new TypedArray([NaN, Infinity, -Infinity, 1]) } + for (const space of [undefined, 2]) { + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + assert.throws(() => stringify.configure({ strict: true })(value, null, space), { + message: 'Object can not safely be stringified. Received type number (NaN)' + }) + } + } + assert.end() +}) + /** * @param {string} key * @param {unknown} value From 1d427ce87ab53d07e37a2c8a501896714ded0681 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 13 Oct 2026 12:56:44 +0900 Subject: [PATCH 2/2] perf(typed-array): inline floating-point checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A formatter closure adds finite checks to integer arrays and an extra call for floating-point values. Inline checks retain direct conversion for integer and BigInt arrays. On Node 24.20.0 (V8 13.6.233.17-node.53), CPU time per call falls from 4.92 to 4.11 µs for 128 indented Int32Array values. For 128 compact Float64Array values, it falls from 8.27 to 7.54 µs. These means use two fresh processes with 50k warmup calls and seven trials per shape, excluding the extremes. Refs: https://github.com/BridgeAR/safe-stable-stringify/pull/69 --- index.js | 44 ++++++++++++++++++++++--------- test.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 101 insertions(+), 23 deletions(-) diff --git a/index.js b/index.js index 53f6c9e..f58a2b4 100644 --- a/index.js +++ b/index.js @@ -61,25 +61,43 @@ const typedArrayPrototypeGetSymbolToStringTag = Symbol.toStringTag ).get -function isTypedArrayWithEntries (value) { +/** @param {object & {length?: unknown}} value */ +function getTypedArrayType (value) { // @ts-expect-error - return typedArrayPrototypeGetSymbolToStringTag.call(value) !== undefined && value.length !== 0 + const type = typedArrayPrototypeGetSymbolToStringTag.call(value) + return type !== undefined && value.length !== 0 ? type : undefined } +/** @param {number | bigint} value */ function wrapInQuotes (value) { return `"${value}"` } -function stringifyTypedArray (array, separator, maximumBreadth, bigint, fail) { +/** + * @param {ArrayLike} array + * @param {string} separator + * @param {number} maximumBreadth + * @param {boolean | 'string'} bigint + * @param {((value: number | bigint) => never) | undefined} fail + * @param {string} type + */ +function stringifyTypedArray (array, separator, maximumBreadth, bigint, fail, type) { if (array.length < maximumBreadth) { maximumBreadth = array.length } + const whitespace = separator === ',' ? '' : ' ' + if (type[0] === 'F') { + let value = array[0] + let res = `"0":${whitespace}${Number.isFinite(value) ? value : fail ? fail(value) : 'null'}` + for (let i = 1; i < maximumBreadth; i++) { + value = array[i] + res += `${separator}"${i}":${whitespace}${Number.isFinite(value) ? value : fail ? fail(value) : 'null'}` + } + return res + } const method = bigint === 'string' && array.length > 0 && typeof array[0] === 'bigint' ? wrapInQuotes - : (value) => typeof value === 'number' && !isFinite(value) - ? fail ? fail(value) : 'null' - : String(value) - const whitespace = separator === ',' ? '' : ' ' + : String let res = `"0":${whitespace}${method(array[0])}` for (let i = 1; i < maximumBreadth; i++) { res += `${separator}"${i}":${whitespace}${method(array[i])}` @@ -334,7 +352,7 @@ function configure (options) { whitespace = ' ' } const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (deterministic && !isTypedArrayWithEntries(value)) { + if (deterministic && getTypedArrayType(value) === undefined) { keys = sort(keys, comparator) } stack.push(value) @@ -538,8 +556,9 @@ function configure (options) { let res = '' let separator = '' let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, join, maximumBreadth, bigint, fail) + const typedArrayType = getTypedArrayType(value) + if (typedArrayType !== undefined) { + res += stringifyTypedArray(value, join, maximumBreadth, bigint, fail, typedArrayType) keys = keys.slice(value.length) maximumPropertiesToStringify -= value.length separator = join @@ -647,8 +666,9 @@ function configure (options) { } let separator = '' let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (hasLength && isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, ',', maximumBreadth, bigint, fail) + const typedArrayType = hasLength ? getTypedArrayType(value) : undefined + if (typedArrayType !== undefined) { + res += stringifyTypedArray(value, ',', maximumBreadth, bigint, fail, typedArrayType) keys = keys.slice(value.length) maximumPropertiesToStringify -= value.length separator = ',' diff --git a/test.js b/test.js index fa54c81..7da2985 100644 --- a/test.js +++ b/test.js @@ -2,19 +2,77 @@ const { test } = require('tap') const { stringify } = require('./index.js') const clone = require('clone') const { constants: { MAX_STRING_LENGTH } } = require('buffer') +const { runInNewContext } = require('vm') + +for (const name of ['Float16Array', 'Float32Array', 'Float64Array']) { + const TypedArray = globalThis[/** @type {'Float16Array' | 'Float32Array' | 'Float64Array'} */ (name)] + test(`${name} serializes non-finite values`, { skip: TypedArray === undefined }, + /** @param {import('tap').Test} assert */ + (assert) => { + const strict = stringify.configure({ strict: true }) + for (const space of [undefined, 2]) { + for (const number of [NaN, Infinity, -Infinity]) { + for (const values of [[number, 1], [1, number]]) { + const value = { metrics: new TypedArray(values) } + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + assert.throws(() => strict(value, null, space), { + message: `Object can not safely be stringified. Received type number (${number})` + }) + } + } + const finite = new TypedArray([-0, 1.5, -2]) + assert.equal(strict(finite, null, space), JSON.stringify(finite, null, space)) + const empty = new TypedArray(0) + assert.equal(strict(empty, null, space), JSON.stringify(empty, null, space)) + const limited = stringify.configure({ strict: true, maximumBreadth: 1 }) + assert.equal(limited(new TypedArray([1, NaN]), null, space), + JSON.stringify({ 0: 1, '...': '1 item not stringified' }, null, space)) + } + assert.end() + }) +} -test('non-finite floating typed arrays produce valid JSON', (assert) => { - for (const TypedArray of [Float32Array, Float64Array]) { - const value = { metrics: new TypedArray([NaN, Infinity, -Infinity, 1]) } - for (const space of [undefined, 2]) { - assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) - assert.throws(() => stringify.configure({ strict: true })(value, null, space), { - message: 'Object can not safely be stringified. Received type number (NaN)' - }) +test('floating typed arrays retain their intrinsic type', + /** @param {import('tap').Test} assert */ + (assert) => { + class FloatSubclass extends Float32Array {} + const foreign = runInNewContext('new Float64Array([1, Infinity])') + const subclass = new FloatSubclass([1, -Infinity]) + const disguised = new Float32Array([1, NaN]) + Object.defineProperties(disguised, { + constructor: { value: Int32Array }, + [Symbol.toStringTag]: { value: 'Int32Array' } + }) + for (const value of [foreign, subclass, disguised]) { + for (const space of [undefined, 2]) { + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + } } - } - assert.end() -}) + assert.end() + }) + +test('integer and bigint typed arrays retain their formatting', + /** @param {import('tap').Test} assert */ + (assert) => { + for (const TypedArray of [Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, + Uint16Array, Int32Array, Uint32Array]) { + const value = new TypedArray([0, 1, 2]) + for (const space of [undefined, 2]) { + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + } + } + for (const TypedArray of [BigInt64Array, BigUint64Array]) { + const value = new TypedArray([0n, 1n, 2n]) + for (const bigint of /** @type {const} */ ([true, 'string'])) { + const serialize = stringify.configure({ bigint }) + const expected = bigint === 'string' ? { 0: '0', 1: '1', 2: '2' } : { 0: 0, 1: 1, 2: 2 } + for (const space of [undefined, 2]) { + assert.equal(serialize(value, null, space), JSON.stringify(expected, null, space)) + } + } + } + assert.end() + }) /** * @param {string} key