Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ src/index.ts
src/errors/SquareError.ts
src/core/index.ts
src/core/crypto
src/core/json.ts
tests/unit/error.test.ts
tests/unit/fetcher/stream-wrappers/webpack.test.ts
tests/integration
Expand Down
28 changes: 28 additions & 0 deletions jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export default {
roots: ["<rootDir>/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",
Expand All @@ -24,6 +31,13 @@ export default {
roots: ["<rootDir>/tests"],
testMatch: ["<rootDir>/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",
Expand All @@ -34,6 +48,13 @@ export default {
},
roots: ["<rootDir>/tests/wire"],
setupFilesAfterEnv: ["<rootDir>/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",
Expand All @@ -43,6 +64,13 @@ export default {
"^(\.{1,2}/.*)\.js$": "$1",
},
roots: ["<rootDir>/tests/integration"],
transformIgnorePatterns: [
"node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)",
],
transform: {
"^.+\\.tsx?$": "ts-jest",
"^.+\\.m?jsx?$": "ts-jest",
},
},
],
workerThreads: false,
Expand Down
160 changes: 108 additions & 52 deletions src/core/json.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

/**
Expand All @@ -73,28 +96,61 @@ export const toJson = (
* @returns Parsed object, array, or other type
*/
export function fromJson<T = unknown>(
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*|(?<![^\\]"\n*\s*:\n*\s*[^\\]".*),\n*\s*)(-?\d{17,}|-?(?:[9](?:[1-9]07199254740991|0[1-9]7199254740991|00[8-9]199254740991|007[2-9]99254740991|007199[3-9]54740991|0071992[6-9]4740991|00719925[5-9]740991|007199254[8-9]40991|0071992547[5-9]0991|00719925474[1-9]991|00719925474099[2-9])))(?=,(?!.*[^\\]"(\n*\s*\}|\n*\s*\]))|\n*\s*\}[^"]?|\n*\s*\][^"])/g;
const serializedData = json.replace(numbersBiggerThanMaxInt, '"$1n"');
const MAX_INT = Number.MAX_SAFE_INTEGER.toString();
const MAX_DIGITS = MAX_INT.length;
const stringsOrLargeNumbers = /"(?:[^"\\]|\\.)*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/g;
const noiseValueWithQuotes = /^"-?\d+n+"$/; // Noise - strings that match the custom format before being converted to it
const customFormat = /^-?\d+n$/;

// Find and mark big numbers with "n"
const serializedData = text.replace(
stringsOrLargeNumbers,
(match, digits, fractional, exponential) => {
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);
});
}