Skip to content

Commit

Permalink
Implements a JSON.parse that transforms unsafe numbers to bigints
Browse files Browse the repository at this point in the history
  • Loading branch information
lorisleiva committed Aug 21, 2024
1 parent 04942c9 commit addd8e9
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ describe('createHttpTransport and `AbortSignal`', () => {
it('resolves with the response', async () => {
expect.assertions(1);
jest.mocked(fetchSpy).mockResolvedValueOnce({
json: () => ({ ok: true }),
ok: true,
text: () => '{"ok":true}',
} as unknown as Response);
const sendPromise = makeHttpRequest({ payload: 123, signal: abortSignal });
abortController.abort('I got bored waiting');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ describe('createHttpTransport', () => {
describe('when the endpoint returns a well-formed JSON response', () => {
beforeEach(() => {
fetchSpy.mockResolvedValue({
json: () => ({ ok: true }),
ok: true,
text: () => '{"ok":true}',
});
});
it('calls fetch with the specified URL', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { jsonParseWithLargeIntegersAsBigInts, wrapLargeIntegers } from '../json-parse-with-bigint';

describe('jsonParseWithLargeNumbersAsBigInts', () => {
it('parsed large integers as bigints', () => {
const value = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
const result = jsonParseWithLargeIntegersAsBigInts(`{"value":${value}}`) as { value: unknown };
expect(result).toEqual({ value });
expect(typeof result.value).toBe('bigint');
});
it('keeps safe integers as numbers', () => {
const value = Number.MAX_SAFE_INTEGER;
const result = jsonParseWithLargeIntegersAsBigInts(`{"value":${value}}`) as { value: unknown };
expect(result).toEqual({ value });
expect(typeof result.value).toBe('number');
});
it('does not affect numbers in strings', () => {
expect(jsonParseWithLargeIntegersAsBigInts(`{"value":"Hello 123 World"}`)).toEqual({
value: 'Hello 123 World',
});
});
it('does not affect numbers in strings with escaped double quotes', () => {
expect(jsonParseWithLargeIntegersAsBigInts(`{"value":"Hello\\" 123 World"}`)).toEqual({
value: 'Hello" 123 World',
});
});
});

describe('wrapLargeIntegers', () => {
it('wraps large integers as bigint strings', () => {
const value = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
expect(wrapLargeIntegers(`{"value":${value}}`)).toBe(`{"value":{"__brand":"bigint","value":"${value}"}}`);
});
it('keeps safe integers as numbers', () => {
const value = Number.MAX_SAFE_INTEGER;
expect(wrapLargeIntegers(`{"value":${value}}`)).toBe(`{"value":${value}}`);
});
it('does not wrap numbers in strings', () => {
expect(wrapLargeIntegers(`{"value":"Hello 123 World"}`)).toBe(`{"value":"Hello 123 World"}`);
});
it('does not wrap numbers in strings with escaped double quotes', () => {
expect(wrapLargeIntegers(`{"value":"Hello\\" 123 World"}`)).toBe(`{"value":"Hello\\" 123 World"}`);
});
});
3 changes: 2 additions & 1 deletion packages/rpc-transport-http/src/http-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
assertIsAllowedHttpRequestHeaders,
normalizeHeaders,
} from './http-transport-headers';
import { jsonParseWithLargeIntegersAsBigInts } from './json-parse-with-bigint';

type Config = Readonly<{
dispatcher_NODE_ONLY?: Dispatcher;
Expand Down Expand Up @@ -66,6 +67,6 @@ export function createHttpTransport(config: Config): RpcTransport {
statusCode: response.status,
});
}
return (await response.json()) as TResponse;
return jsonParseWithLargeIntegersAsBigInts(await response.text()) as TResponse;
};
}
76 changes: 76 additions & 0 deletions packages/rpc-transport-http/src/json-parse-with-bigint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* This function is a replacement for `JSON.parse` that can handle large integers by
* parsing them as BigInts. It will only transform integers that are too large to be
* represented as a regular JavaScript number.
*/
export function jsonParseWithLargeIntegersAsBigInts(json: string): unknown {
return JSON.parse(wrapLargeIntegers(json), (_, value) => {
return isWrappedBigInt(value) ? unwrapBigInt(value) : value;
});
}

/**
* This function takes a JSON string and wraps any large, unsafe integers in a special
* format that can be later recognized and unwrapped by a `JSON.parse` reviver.
*/
export function wrapLargeIntegers(json: string): string {
return transformUnquotedSegments(json, unquotedSegment => {
return unquotedSegment.replaceAll(/(-?\d+)/g, (_, value: string) => {
return Number.isSafeInteger(Number(value)) ? value : wrapBigInt(value);
});
});
}

/**
* This function takes a JSON string and transforms any unquoted segments using the provided
* `transform` function. This means we can be sure that our transformations will never occur
* inside a double quoted string — even if it contains escaped double quotes.
*/
function transformUnquotedSegments(json: string, transform: (value: string) => string): string {
/**
* This regex matches any part of a JSON string that isn't wrapped in double quotes.
*
* For instance, in the string `{"age":42,"name":"Alice \"The\" 2nd"}`, it would the
* following parts: `{`, `:42,`, `:`, `}`. Notice the whole "Alice \"The\" 2nd" string
* is not matched as it is wrapped in double quotes and contains escaped double quotes.
*
* The regex is composed of two parts:
*
* 1. The first part `^([^"]+)` matches any character until we reach the first double quote.
* 2. The second part `("(?:\\"|[^"])+")([^"]+)` matches any double quoted string that may
* and any unquoted segment that follows it. To match a double quoted string, we use the
* `(?:\\"|[^"])` regex to match any character that isn't a double quote whilst allowing
* escaped double quotes.
*/
const unquotedSegmentsRegex = /^([^"]+)|("(?:\\"|[^"])+")([^"]+)/g;

return json.replaceAll(unquotedSegmentsRegex, (_, firstGroup, secondGroup, thirdGroup) => {
// If the first group is matched, it means we are at the
// beginning of the JSON string and we have an unquoted segment.
if (firstGroup) return transform(firstGroup);

// Otherwise, we have a double quoted string followed by an unquoted segment.
return `${secondGroup}${transform(thirdGroup)}`;
});
}

type WrappedBigInt = { __brand: 'bigint'; value: string };

function wrapBigInt(value: string): string {
return `{"__brand":"bigint","value":"${value}"}`;
}

function unwrapBigInt({ value }: WrappedBigInt): bigint {
return BigInt(value);
}

function isWrappedBigInt(value: unknown): value is WrappedBigInt {
return (
!!value &&
typeof value === 'object' &&
'__brand' in value &&
value.__brand === 'bigint' &&
'value' in value &&
typeof value.value === 'string'
);
}
2 changes: 1 addition & 1 deletion packages/rpc-transport-http/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"lib": ["DOM", "ES2022.Error"]
"lib": ["DOM", "ES2021.String", "ES2022.Error"]
},
"display": "@solana/rpc-transport-http",
"extends": "../tsconfig/base.json",
Expand Down

0 comments on commit addd8e9

Please sign in to comment.