diff --git a/jest.config.mjs b/jest.config.mjs index eaf4295b5..5158ac4fb 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -13,6 +13,13 @@ export default { roots: ["/tests"], testPathIgnorePatterns: ["\.browser\.(spec|test)\.[jt]sx?$", "/tests/wire/", "/tests/integration/"], setupFilesAfterEnv: [], + transformIgnorePatterns: [ + "node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)", + ], + transform: { + "^.+\\.tsx?$": "ts-jest", + "^.+\\.m?jsx?$": "ts-jest", + }, }, { displayName: "browser", @@ -24,6 +31,13 @@ export default { roots: ["/tests"], testMatch: ["/tests/unit/**/?(*.)+(browser).(spec|test).[jt]s?(x)"], setupFilesAfterEnv: [], + transformIgnorePatterns: [ + "node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)", + ], + transform: { + "^.+\\.tsx?$": "ts-jest", + "^.+\\.m?jsx?$": "ts-jest", + }, }, { displayName: "wire", @@ -34,6 +48,13 @@ export default { }, roots: ["/tests/wire"], setupFilesAfterEnv: ["/tests/mock-server/setup.ts"], + transformIgnorePatterns: [ + "node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)", + ], + transform: { + "^.+\\.tsx?$": "ts-jest", + "^.+\\.m?jsx?$": "ts-jest", + }, }, { displayName: "integration", @@ -43,6 +64,13 @@ export default { "^(\.{1,2}/.*)\.js$": "$1", }, roots: ["/tests/integration"], + transformIgnorePatterns: [ + "node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)", + ], + transform: { + "^.+\\.tsx?$": "ts-jest", + "^.+\\.m?jsx?$": "ts-jest", + }, }, ], workerThreads: false, diff --git a/src/core/json.ts b/src/core/json.ts index cca884d32..f5e294921 100644 --- a/src/core/json.ts +++ b/src/core/json.ts @@ -1,69 +1,92 @@ // Credit to Ivan Korolenko // Code adopted from https://github.com/Ivan-Korolenko/json-with-bigint +// Based on upstream commit 79f8c9eec0017eff0b89b371c045962e5c2da709 (v3.4.4, April 2025) + +const noiseValue = /^-?\d+n+$/; // Noise - strings that match the custom format before being converted to it +const originalStringify = JSON.stringify; +const originalParse = JSON.parse; /* - Function to serialize data to JSON string - Converts BigInt values to custom format (strings with digits and "n" at the end) and then converts them to proper big integers in JSON string + Function to serialize value to a JSON string. + Converts BigInt values to a custom format (strings with digits and "n" at the end) and then converts them to proper big integers in a JSON string. */ /** * Serialize a value to JSON * @param value A JavaScript value, usually an object or array, to be converted. - * @param replacer A function that transforms the results. + * @param replacer A function that transforms the results, or an array of strings and numbers that acts as an approved list for selecting object properties. * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. * @returns JSON string */ export const toJson = ( - data: unknown, - replacer?: (this: unknown, key: string, value: unknown) => unknown, + value: unknown, + replacer?: ((this: unknown, key: string, value: unknown) => unknown) | (string | number)[] | null, space?: string | number, ): string => { - if (typeof data === "bigint") { - return data.toString(); + // Use native JSON.rawJSON if available (Node 20.12+, Chrome 114+) + if ('rawJSON' in JSON) { + return originalStringify( + value, + (key, val) => { + if (typeof val === 'bigint') { + return (JSON as unknown as { rawJSON: (text: string) => unknown }).rawJSON(val.toString()); + } + + if (typeof replacer === 'function') { + return replacer.call(this, key, val); + } + + if (Array.isArray(replacer) && replacer.includes(key)) { + return val; + } + + return val; + }, + space, + ); } - if (typeof data !== "object") { - return JSON.stringify(data, replacer, space); + + if (!value) { + return originalStringify(value, replacer as never, space); } - // eslint-disable-next-line no-useless-escape - const bigInts = /([\[:])?"(-?\d+)n"([,\}\]])/g; - const preliminaryJSON = JSON.stringify( - data, - (key, value) => - typeof value === "bigint" - ? value.toString() + "n" - : typeof replacer === "undefined" - ? value - : replacer(key, value), + const bigInts = /([\[:])?"(-?\d+)n"($|\s*[,\}\]])/g; + const noise = /([\[:])?("-?\d+n+)n("$|"\s*[,\}\]])/g; + const convertedToCustomJSON = originalStringify( + value, + (key, val) => { + const isNoise = typeof val === 'string' && Boolean(val.match(noiseValue)); + + if (isNoise) { + return val.toString() + 'n'; // Mark noise values with additional "n" to offset the deletion of one "n" during the processing + } + + if (typeof val === 'bigint') { + return val.toString() + 'n'; + } + + if (typeof replacer === 'function') { + return replacer.call(this, key, val); + } + + if (Array.isArray(replacer) && replacer.includes(key)) { + return val; + } + + return val; + }, space, ); - const finalJSON = preliminaryJSON.replace(bigInts, "$1$2$3"); + const processedJSON = convertedToCustomJSON.replace(bigInts, '$1$2$3'); // Delete one "n" off the end of every BigInt value + const denoisedJSON = processedJSON.replace(noise, '$1$2$3'); // Remove one "n" off the end of every noisy string - return finalJSON; + return denoisedJSON; }; /* - Function to parse JSON - If JSON has values presented in a lib's custom format (strings with digits and "n" character at the end), we just parse them to BigInt values (for backward compatibility with previous versions of the lib) - If JSON has values greater than Number.MAX_SAFE_INTEGER, we convert those values to our custom format, then parse them to BigInt values. + Function to parse JSON. + If JSON has number values greater than Number.MAX_SAFE_INTEGER, we convert those values to a custom format, then parse them to BigInt values. Other types of values are not affected and parsed as native JSON.parse() would parse them. - - Big numbers are found and marked using a Regular Expression with these conditions: - - 1. Before the match there is no . and one of the following is present: - 1.1 ": - 1.2 ":[ - 1.3 ":[anyNumberOf(anyCharacters) - 1.4 , with no ":"anyNumberOf(anyCharacters) before it - All " required in the rule are not escaped. All whitespace and newline characters outside of string values are ignored. - - 2. The match itself has more than 16 digits OR (16 digits and any digit of the number is greater than that of the Number.MAX_SAFE_INTEGER). And it may have a - sign at the start. - - 3. After the match one of the following is present: - 3.1 , without anyNumberOf(anyCharacters) "} or anyNumberOf(anyCharacters) "] after it - 3.2 } without " after it - 3.3 ] without " after it - All whitespace and newline characters outside of string values are ignored. */ /** @@ -73,28 +96,61 @@ export const toJson = ( * @returns Parsed object, array, or other type */ export function fromJson( - json: string, + text: string, reviver?: (this: unknown, key: string, value: unknown) => unknown, ): T { - if (!json) { - return JSON.parse(json, reviver); + if (!text) { + return originalParse(text, reviver); } - const numbersBiggerThanMaxInt = // eslint-disable-next-line no-useless-escape - /(?<=[^\\]":\n*\s*[\[]?|[^\\]":\n*\s*\[.*[^\.\d*]\n*\s*|(? { + const isString = match[0] === '"'; + const isNoise = isString && Boolean(match.match(noiseValueWithQuotes)); - return JSON.parse(serializedData, (key, value) => { - const isCustomFormatBigInt = typeof value === "string" && Boolean(value.match(/^-?\d+n$/)); + if (isNoise) { + return match.substring(0, match.length - 1) + 'n"'; // Mark noise values with additional "n" to offset the deletion of one "n" during the processing + } + + const isFractionalOrExponential = fractional || exponential; + const isLessThanMaxSafeInt = + digits && + (digits.length < MAX_DIGITS || (digits.length === MAX_DIGITS && digits <= MAX_INT)); // With a fixed number of digits, we can correctly use lexicographical comparison to do a numeric comparison + + if (isString || isFractionalOrExponential || isLessThanMaxSafeInt) { + return match; + } + + return '"' + match + 'n"'; + }, + ); + + // Convert marked big numbers to BigInt + return originalParse(serializedData, function (key, value) { + const isCustomFormatBigInt = typeof value === 'string' && Boolean(value.match(customFormat)); if (isCustomFormatBigInt) { return BigInt(value.substring(0, value.length - 1)); } - if (typeof reviver !== "undefined") { - return reviver(key, value); + const isNoiseValue = typeof value === 'string' && Boolean(value.match(noiseValue)); + + if (isNoiseValue) { + return value.substring(0, value.length - 1); // Remove one "n" off the end of the noisy string + } + + if (typeof reviver !== 'function') { + return value; } - return value; + return reviver.call(this, key, value); }); }