Skip to content

Commit 693a58c

Browse files
authored
fix perf in json parsing logic (#207)
* fix perf in json parsing logic * fix tests * further improve regex perf * fernignore
1 parent c0db9e3 commit 693a58c

File tree

3 files changed

+137
-52
lines changed

3 files changed

+137
-52
lines changed

.fernignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ src/index.ts
99
src/errors/SquareError.ts
1010
src/core/index.ts
1111
src/core/crypto
12+
src/core/json.ts
1213
tests/unit/error.test.ts
1314
tests/unit/fetcher/stream-wrappers/webpack.test.ts
1415
tests/integration

jest.config.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export default {
1313
roots: ["<rootDir>/tests"],
1414
testPathIgnorePatterns: ["\.browser\.(spec|test)\.[jt]sx?$", "/tests/wire/", "/tests/integration/"],
1515
setupFilesAfterEnv: [],
16+
transformIgnorePatterns: [
17+
"node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)",
18+
],
19+
transform: {
20+
"^.+\\.tsx?$": "ts-jest",
21+
"^.+\\.m?jsx?$": "ts-jest",
22+
},
1623
},
1724
{
1825
displayName: "browser",
@@ -24,6 +31,13 @@ export default {
2431
roots: ["<rootDir>/tests"],
2532
testMatch: ["<rootDir>/tests/unit/**/?(*.)+(browser).(spec|test).[jt]s?(x)"],
2633
setupFilesAfterEnv: [],
34+
transformIgnorePatterns: [
35+
"node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)",
36+
],
37+
transform: {
38+
"^.+\\.tsx?$": "ts-jest",
39+
"^.+\\.m?jsx?$": "ts-jest",
40+
},
2741
},
2842
{
2943
displayName: "wire",
@@ -34,6 +48,13 @@ export default {
3448
},
3549
roots: ["<rootDir>/tests/wire"],
3650
setupFilesAfterEnv: ["<rootDir>/tests/mock-server/setup.ts"],
51+
transformIgnorePatterns: [
52+
"node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)",
53+
],
54+
transform: {
55+
"^.+\\.tsx?$": "ts-jest",
56+
"^.+\\.m?jsx?$": "ts-jest",
57+
},
3758
},
3859
{
3960
displayName: "integration",
@@ -43,6 +64,13 @@ export default {
4364
"^(\.{1,2}/.*)\.js$": "$1",
4465
},
4566
roots: ["<rootDir>/tests/integration"],
67+
transformIgnorePatterns: [
68+
"node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async)/)",
69+
],
70+
transform: {
71+
"^.+\\.tsx?$": "ts-jest",
72+
"^.+\\.m?jsx?$": "ts-jest",
73+
},
4674
},
4775
],
4876
workerThreads: false,

src/core/json.ts

Lines changed: 108 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,92 @@
11
// Credit to Ivan Korolenko
22
// Code adopted from https://github.com/Ivan-Korolenko/json-with-bigint
3+
// Based on upstream commit 79f8c9eec0017eff0b89b371c045962e5c2da709 (v3.4.4, April 2025)
4+
5+
const noiseValue = /^-?\d+n+$/; // Noise - strings that match the custom format before being converted to it
6+
const originalStringify = JSON.stringify;
7+
const originalParse = JSON.parse;
38

49
/*
5-
Function to serialize data to JSON string
6-
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
10+
Function to serialize value to a JSON string.
11+
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.
712
*/
813

914
/**
1015
* Serialize a value to JSON
1116
* @param value A JavaScript value, usually an object or array, to be converted.
12-
* @param replacer A function that transforms the results.
17+
* @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.
1318
* @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
1419
* @returns JSON string
1520
*/
1621
export const toJson = (
17-
data: unknown,
18-
replacer?: (this: unknown, key: string, value: unknown) => unknown,
22+
value: unknown,
23+
replacer?: ((this: unknown, key: string, value: unknown) => unknown) | (string | number)[] | null,
1924
space?: string | number,
2025
): string => {
21-
if (typeof data === "bigint") {
22-
return data.toString();
26+
// Use native JSON.rawJSON if available (Node 20.12+, Chrome 114+)
27+
if ('rawJSON' in JSON) {
28+
return originalStringify(
29+
value,
30+
(key, val) => {
31+
if (typeof val === 'bigint') {
32+
return (JSON as unknown as { rawJSON: (text: string) => unknown }).rawJSON(val.toString());
33+
}
34+
35+
if (typeof replacer === 'function') {
36+
return replacer.call(this, key, val);
37+
}
38+
39+
if (Array.isArray(replacer) && replacer.includes(key)) {
40+
return val;
41+
}
42+
43+
return val;
44+
},
45+
space,
46+
);
2347
}
24-
if (typeof data !== "object") {
25-
return JSON.stringify(data, replacer, space);
48+
49+
if (!value) {
50+
return originalStringify(value, replacer as never, space);
2651
}
2752

28-
// eslint-disable-next-line no-useless-escape
29-
const bigInts = /([\[:])?"(-?\d+)n"([,\}\]])/g;
30-
const preliminaryJSON = JSON.stringify(
31-
data,
32-
(key, value) =>
33-
typeof value === "bigint"
34-
? value.toString() + "n"
35-
: typeof replacer === "undefined"
36-
? value
37-
: replacer(key, value),
53+
const bigInts = /([\[:])?"(-?\d+)n"($|\s*[,\}\]])/g;
54+
const noise = /([\[:])?("-?\d+n+)n("$|"\s*[,\}\]])/g;
55+
const convertedToCustomJSON = originalStringify(
56+
value,
57+
(key, val) => {
58+
const isNoise = typeof val === 'string' && Boolean(val.match(noiseValue));
59+
60+
if (isNoise) {
61+
return val.toString() + 'n'; // Mark noise values with additional "n" to offset the deletion of one "n" during the processing
62+
}
63+
64+
if (typeof val === 'bigint') {
65+
return val.toString() + 'n';
66+
}
67+
68+
if (typeof replacer === 'function') {
69+
return replacer.call(this, key, val);
70+
}
71+
72+
if (Array.isArray(replacer) && replacer.includes(key)) {
73+
return val;
74+
}
75+
76+
return val;
77+
},
3878
space,
3979
);
40-
const finalJSON = preliminaryJSON.replace(bigInts, "$1$2$3");
80+
const processedJSON = convertedToCustomJSON.replace(bigInts, '$1$2$3'); // Delete one "n" off the end of every BigInt value
81+
const denoisedJSON = processedJSON.replace(noise, '$1$2$3'); // Remove one "n" off the end of every noisy string
4182

42-
return finalJSON;
83+
return denoisedJSON;
4384
};
4485

4586
/*
46-
Function to parse JSON
47-
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)
48-
If JSON has values greater than Number.MAX_SAFE_INTEGER, we convert those values to our custom format, then parse them to BigInt values.
87+
Function to parse JSON.
88+
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.
4989
Other types of values are not affected and parsed as native JSON.parse() would parse them.
50-
51-
Big numbers are found and marked using a Regular Expression with these conditions:
52-
53-
1. Before the match there is no . and one of the following is present:
54-
1.1 ":
55-
1.2 ":[
56-
1.3 ":[anyNumberOf(anyCharacters)
57-
1.4 , with no ":"anyNumberOf(anyCharacters) before it
58-
All " required in the rule are not escaped. All whitespace and newline characters outside of string values are ignored.
59-
60-
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.
61-
62-
3. After the match one of the following is present:
63-
3.1 , without anyNumberOf(anyCharacters) "} or anyNumberOf(anyCharacters) "] after it
64-
3.2 } without " after it
65-
3.3 ] without " after it
66-
All whitespace and newline characters outside of string values are ignored.
6790
*/
6891

6992
/**
@@ -73,28 +96,61 @@ export const toJson = (
7396
* @returns Parsed object, array, or other type
7497
*/
7598
export function fromJson<T = unknown>(
76-
json: string,
99+
text: string,
77100
reviver?: (this: unknown, key: string, value: unknown) => unknown,
78101
): T {
79-
if (!json) {
80-
return JSON.parse(json, reviver);
102+
if (!text) {
103+
return originalParse(text, reviver);
81104
}
82105

83-
const numbersBiggerThanMaxInt = // eslint-disable-next-line no-useless-escape
84-
/(?<=[^\\]":\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;
85-
const serializedData = json.replace(numbersBiggerThanMaxInt, '"$1n"');
106+
const MAX_INT = Number.MAX_SAFE_INTEGER.toString();
107+
const MAX_DIGITS = MAX_INT.length;
108+
const stringsOrLargeNumbers = /"(?:[^"\\]|\\.)*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/g;
109+
const noiseValueWithQuotes = /^"-?\d+n+"$/; // Noise - strings that match the custom format before being converted to it
110+
const customFormat = /^-?\d+n$/;
111+
112+
// Find and mark big numbers with "n"
113+
const serializedData = text.replace(
114+
stringsOrLargeNumbers,
115+
(match, digits, fractional, exponential) => {
116+
const isString = match[0] === '"';
117+
const isNoise = isString && Boolean(match.match(noiseValueWithQuotes));
86118

87-
return JSON.parse(serializedData, (key, value) => {
88-
const isCustomFormatBigInt = typeof value === "string" && Boolean(value.match(/^-?\d+n$/));
119+
if (isNoise) {
120+
return match.substring(0, match.length - 1) + 'n"'; // Mark noise values with additional "n" to offset the deletion of one "n" during the processing
121+
}
122+
123+
const isFractionalOrExponential = fractional || exponential;
124+
const isLessThanMaxSafeInt =
125+
digits &&
126+
(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
127+
128+
if (isString || isFractionalOrExponential || isLessThanMaxSafeInt) {
129+
return match;
130+
}
131+
132+
return '"' + match + 'n"';
133+
},
134+
);
135+
136+
// Convert marked big numbers to BigInt
137+
return originalParse(serializedData, function (key, value) {
138+
const isCustomFormatBigInt = typeof value === 'string' && Boolean(value.match(customFormat));
89139

90140
if (isCustomFormatBigInt) {
91141
return BigInt(value.substring(0, value.length - 1));
92142
}
93143

94-
if (typeof reviver !== "undefined") {
95-
return reviver(key, value);
144+
const isNoiseValue = typeof value === 'string' && Boolean(value.match(noiseValue));
145+
146+
if (isNoiseValue) {
147+
return value.substring(0, value.length - 1); // Remove one "n" off the end of the noisy string
148+
}
149+
150+
if (typeof reviver !== 'function') {
151+
return value;
96152
}
97153

98-
return value;
154+
return reviver.call(this, key, value);
99155
});
100156
}

0 commit comments

Comments
 (0)