From 0f853edbce78dcb1fc83395cbcf54294b34e7aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Jakub=20Nani=C5=A1ta?= Date: Wed, 10 Apr 2024 12:54:06 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=9E=EF=B8=8F=20Retry=20functionality?= =?UTF-8?q?=20for=20SDKs=20(#558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/many-toys-explain.md | 11 ++ packages/devtools/.swcrc | 11 ++ packages/devtools/src/common/index.ts | 1 + packages/devtools/src/common/retry.ts | 107 +++++++++++ packages/devtools/test/common/retry.test.ts | 168 ++++++++++++++++++ packages/devtools/tsconfig.json | 1 + packages/protocol-devtools-evm/.swcrc | 11 ++ .../src/endpointv2/sdk.ts | 11 ++ .../protocol-devtools-evm/src/uln302/sdk.ts | 6 +- packages/protocol-devtools-evm/tsconfig.json | 1 + packages/ua-devtools-evm/.swcrc | 11 ++ packages/ua-devtools-evm/src/lzapp/sdk.ts | 2 + packages/ua-devtools-evm/src/oapp/sdk.ts | 9 +- packages/ua-devtools-evm/tsconfig.json | 1 + tsconfig.json | 4 +- 15 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 .changeset/many-toys-explain.md create mode 100644 packages/devtools/.swcrc create mode 100644 packages/devtools/src/common/retry.ts create mode 100644 packages/devtools/test/common/retry.test.ts create mode 100644 packages/protocol-devtools-evm/.swcrc create mode 100644 packages/ua-devtools-evm/.swcrc diff --git a/.changeset/many-toys-explain.md b/.changeset/many-toys-explain.md new file mode 100644 index 000000000..a86369878 --- /dev/null +++ b/.changeset/many-toys-explain.md @@ -0,0 +1,11 @@ +--- +"@layerzerolabs/protocol-devtools-evm": patch +"@layerzerolabs/ua-devtools-evm": patch +"@layerzerolabs/devtools": patch +"@layerzerolabs/devtools-evm": patch +"@layerzerolabs/devtools-evm-hardhat": patch +"@layerzerolabs/toolbox-hardhat": patch +"@layerzerolabs/ua-devtools-evm-hardhat": patch +--- + +Adding `AsyncRetriable` to SDKs diff --git a/packages/devtools/.swcrc b/packages/devtools/.swcrc new file mode 100644 index 000000000..6d5164be0 --- /dev/null +++ b/packages/devtools/.swcrc @@ -0,0 +1,11 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true + }, + "transform": { + "legacyDecorator": true + } + } +} \ No newline at end of file diff --git a/packages/devtools/src/common/index.ts b/packages/devtools/src/common/index.ts index 14c306d99..d7a8d16e4 100644 --- a/packages/devtools/src/common/index.ts +++ b/packages/devtools/src/common/index.ts @@ -1,4 +1,5 @@ export * from './assertion' export * from './bytes' export * from './promise' +export * from './retry' export * from './strings' diff --git a/packages/devtools/src/common/retry.ts b/packages/devtools/src/common/retry.ts new file mode 100644 index 000000000..3b853edad --- /dev/null +++ b/packages/devtools/src/common/retry.ts @@ -0,0 +1,107 @@ +import { createModuleLogger, printJson } from '@layerzerolabs/io-devtools' +import assert from 'assert' +import { backOff } from 'exponential-backoff' + +export type OnRetry = ( + attempt: number, + numAttempts: number, + error: unknown, + target: TInstance, + method: string, + args: TArgs +) => boolean | void | undefined + +export interface RetriableConfig { + /** + * Enable / disable the retry behavior + */ + enabled?: boolean + /** + * The maximum delay, in milliseconds, between two consecutive attempts. + * + * @default Infinity + */ + maxDelay?: number + /** + * Number of times the method call will be retried. The default is 3 + * + * @default 3 + */ + numAttempts?: number + /** + * Callback called on every failed attempt. + * + * @param {number} attempt 1-indexed number of attempt of executing the method + * @param {number} numAttempts Maximum/total number of attempts that will be executed + * @param {unknown} error The error that caused the function to be retried + * @param {unknown} target The object whose method is being retried + * @param {string} method The method name + * @param {unknown[]} args The method parameters + * @returns {boolean | undefined} This function can stop the retry train by returning false + */ + onRetry?: OnRetry +} + +/** + * Helper function that creates a default debug logger for the `onRetry` + * callback of `AsyncRetriable` + */ +export const createDefaultRetryHandler = (loggerName: string = 'AsyncRetriable'): OnRetry => { + const logger = createModuleLogger(loggerName) + + return (attempt, numAttempts, error, target, method, args) => { + logger.debug(`Attempt ${attempt}/${numAttempts}: ${method}() with arguments: ${printJson(args)}: ${error}`) + } +} + +export const AsyncRetriable = ({ + // We'll feature flag this functionality for the time being + enabled = !!process.env.LZ_EXPERIMENTAL_ENABLE_RETRY, + maxDelay, + numAttempts = 3, + onRetry = createDefaultRetryHandler(), +}: RetriableConfig = {}) => { + return function AsyncRetriableDecorator( + target: unknown, + propertyKey: string, + descriptor: TypedPropertyDescriptor<(...args: TArgs) => Promise> + ) { + // If we are disabled, we are disabled + if (!enabled) { + return descriptor + } + + // Grab the original method and ensure that we are decorating a method + const originalMethod = descriptor.value + assert( + typeof originalMethod === 'function', + `AsyncRetriable must be applied to an instance method, ${propertyKey} property looks more like ${typeof originalMethod}` + ) + + // We'll wrap the retry handler from exponential backoff + // to make it a bit nicer to use + // + // - We'll put the attempt as the first argument + // - We'll add the decorator target as the last argument + // + // We'll curry this function so that it can pass the arguments to onRetry + const handleRetry = + (args: TArgs) => + (error: unknown, attempt: number): boolean => + onRetry?.(attempt, numAttempts, error, target, propertyKey, args) ?? true + + // Create the retried method + const retriedMethod = (...args: TArgs): Promise => + backOff(() => originalMethod.apply(target, args), { + // A typical problem in our case is 429 Too many requests + // which would still happen if we didn't introduce a bit of randomness into the delay + jitter: 'full', + maxDelay, + numOfAttempts: numAttempts, + retry: handleRetry(args), + }) + + // return our new descriptor + return (descriptor.value = retriedMethod), descriptor + } +} diff --git a/packages/devtools/test/common/retry.test.ts b/packages/devtools/test/common/retry.test.ts new file mode 100644 index 000000000..e16445e27 --- /dev/null +++ b/packages/devtools/test/common/retry.test.ts @@ -0,0 +1,168 @@ +import { AsyncRetriable } from '@/common/retry' + +describe('common/retry', () => { + describe('AsyncRetriable', () => { + describe('when LZ_EXPERIMENTAL_ENABLE_RETRY is off', () => { + beforeAll(() => { + // We'll enable the AsyncRetriable for these tests + process.env.LZ_EXPERIMENTAL_ENABLE_RETRY = '' + }) + + it('shoult not retry', async () => { + const error = new Error('Told ya') + const handleRetry = jest.fn() + const mock = jest.fn().mockRejectedValue(error) + + class WithAsyncRetriable { + @AsyncRetriable({ onRetry: handleRetry }) + async iAlwaysFail(value: string) { + return mock(value) + } + } + + await expect(new WithAsyncRetriable().iAlwaysFail('y')).rejects.toBe(error) + + expect(mock).toHaveBeenCalledTimes(1) + expect(handleRetry).not.toHaveBeenCalled() + }) + + it('should retry if enabled is set to true', async () => { + const error = new Error('Told ya') + const mock = jest.fn().mockRejectedValue(error) + + class WithAsyncRetriable { + @AsyncRetriable({ enabled: true }) + async iAlwaysFail(value: string) { + return mock(value) + } + } + + await expect(new WithAsyncRetriable().iAlwaysFail('y')).rejects.toBe(error) + + expect(mock).toHaveBeenCalledTimes(3) + expect(mock).toHaveBeenNthCalledWith(1, 'y') + expect(mock).toHaveBeenNthCalledWith(2, 'y') + expect(mock).toHaveBeenNthCalledWith(3, 'y') + }) + }) + + describe('when LZ_EXPERIMENTAL_ENABLE_RETRY is on', () => { + beforeAll(() => { + // We'll enable the AsyncRetriable for these tests + process.env.LZ_EXPERIMENTAL_ENABLE_RETRY = '1' + }) + + afterAll(() => { + process.env.LZ_EXPERIMENTAL_ENABLE_RETRY = '' + }) + + it('should retry a method call 3 times by default', async () => { + const error = new Error('Told ya') + const mock = jest.fn().mockRejectedValue(error) + + class WithAsyncRetriable { + @AsyncRetriable() + async iAlwaysFail(value: string) { + return mock(value) + } + } + + await expect(new WithAsyncRetriable().iAlwaysFail('y')).rejects.toBe(error) + + expect(mock).toHaveBeenCalledTimes(3) + expect(mock).toHaveBeenNthCalledWith(1, 'y') + expect(mock).toHaveBeenNthCalledWith(2, 'y') + expect(mock).toHaveBeenNthCalledWith(3, 'y') + }) + + it('should retry a method call N times if numAttempts is specified', async () => { + const error = new Error('Told ya') + const mock = jest.fn().mockRejectedValue(error) + + class WithAsyncRetriable { + @AsyncRetriable({ numAttempts: 2 }) + async iAlwaysFail(value: string) { + return mock(value) + } + } + + await expect(new WithAsyncRetriable().iAlwaysFail('y')).rejects.toBe(error) + + expect(mock).toHaveBeenCalledTimes(2) + }) + + it('should stop retrying if the onRetry handler returns false', async () => { + const error = new Error('Told ya') + const mock = jest.fn().mockRejectedValue(error) + const handleRetry = jest + .fn() + // We check that if we return undefined/void we'll keep trying + .mockReturnValueOnce(undefined) + // We check that if we return true we keep trying + .mockReturnValueOnce(true) + // After the third attempt we return false + .mockReturnValueOnce(false) + + class WithAsyncRetriable { + @AsyncRetriable({ numAttempts: 10_000, onRetry: handleRetry }) + async iAlwaysFail(value: string) { + return mock(value) + } + } + + await expect(new WithAsyncRetriable().iAlwaysFail('y')).rejects.toBe(error) + + expect(mock).toHaveBeenCalledTimes(3) + expect(handleRetry).toHaveBeenCalledTimes(3) + }) + + it('should call the onRetry callback if provided', async () => { + const error = new Error('Told ya') + const handleRetry = jest.fn() + const mock = jest.fn().mockRejectedValue(error) + + class WithAsyncRetriable { + @AsyncRetriable({ onRetry: handleRetry }) + async iAlwaysFail(value: string) { + return mock(value) + } + } + + const withAsyncRetriable = new WithAsyncRetriable() + + await expect(withAsyncRetriable.iAlwaysFail('y')).rejects.toBe(error) + + expect(handleRetry).toHaveBeenCalledTimes(3) + expect(handleRetry).toHaveBeenNthCalledWith(1, 1, 3, error, withAsyncRetriable, 'iAlwaysFail', ['y']) + expect(handleRetry).toHaveBeenNthCalledWith(2, 2, 3, error, withAsyncRetriable, 'iAlwaysFail', ['y']) + expect(handleRetry).toHaveBeenNthCalledWith(3, 3, 3, error, withAsyncRetriable, 'iAlwaysFail', ['y']) + }) + + it('should resolve if the method resolves within the specified number of attempts', async () => { + const error = new Error('Told ya') + const value = {} + const handleRetry = jest.fn() + const mock = jest + .fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValue(value) + + class WithAsyncRetriable { + @AsyncRetriable({ onRetry: handleRetry }) + async iAlwaysFail(value: string) { + return mock(value) + } + } + + const withAsyncRetriable = new WithAsyncRetriable() + + await expect(withAsyncRetriable.iAlwaysFail('y')).resolves.toBe(value) + + expect(handleRetry).toHaveBeenCalledTimes(2) + expect(handleRetry).toHaveBeenNthCalledWith(1, 1, 3, error, withAsyncRetriable, 'iAlwaysFail', ['y']) + expect(handleRetry).toHaveBeenNthCalledWith(2, 2, 3, error, withAsyncRetriable, 'iAlwaysFail', ['y']) + }) + }) + }) +}) diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json index f083b2ecb..c7e0734a3 100644 --- a/packages/devtools/tsconfig.json +++ b/packages/devtools/tsconfig.json @@ -3,6 +3,7 @@ "exclude": ["dist", "node_modules"], "include": ["src", "test"], "compilerOptions": { + "experimentalDecorators": true, "types": ["node", "jest"], "paths": { "@/*": ["./src/*"] diff --git a/packages/protocol-devtools-evm/.swcrc b/packages/protocol-devtools-evm/.swcrc new file mode 100644 index 000000000..6d5164be0 --- /dev/null +++ b/packages/protocol-devtools-evm/.swcrc @@ -0,0 +1,11 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true + }, + "transform": { + "legacyDecorator": true + } + } +} \ No newline at end of file diff --git a/packages/protocol-devtools-evm/src/endpointv2/sdk.ts b/packages/protocol-devtools-evm/src/endpointv2/sdk.ts index a859bc2cc..04406578f 100644 --- a/packages/protocol-devtools-evm/src/endpointv2/sdk.ts +++ b/packages/protocol-devtools-evm/src/endpointv2/sdk.ts @@ -18,6 +18,7 @@ import { isZero, ignoreZero, areBytes32Equal, + AsyncRetriable, } from '@layerzerolabs/devtools' import type { EndpointId } from '@layerzerolabs/lz-definitions' import { makeZeroAddress, type OmniContract, OmniSDK } from '@layerzerolabs/devtools-evm' @@ -42,6 +43,7 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { super(contract) } + @AsyncRetriable() async getDelegate(oapp: OmniAddress): Promise { this.logger.debug(`Getting delegate for OApp ${oapp}`) @@ -67,18 +69,21 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { return await this.uln302Factory({ eid: this.point.eid, address }) } + @AsyncRetriable() async getDefaultReceiveLibrary(eid: EndpointId): Promise { this.logger.debug(`Getting default receive library for eid ${eid} (${formatEid(eid)})`) return ignoreZero(await this.contract.contract.defaultReceiveLibrary(eid)) } + @AsyncRetriable() async getSendLibrary(sender: OmniAddress, dstEid: EndpointId): Promise { this.logger.debug(`Getting send library for eid ${dstEid} (${formatEid(dstEid)}) and address ${sender}`) return ignoreZero(await this.contract.contract.getSendLibrary(sender, dstEid)) } + @AsyncRetriable() async getReceiveLibrary( receiver: OmniAddress, srcEid: EndpointId @@ -109,12 +114,14 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { } } + @AsyncRetriable() async getDefaultSendLibrary(eid: EndpointId): Promise { this.logger.debug(`Getting default send library for eid ${eid} (${formatEid(eid)})`) return ignoreZero(await this.contract.contract.defaultSendLibrary(eid)) } + @AsyncRetriable() async isDefaultSendLibrary(sender: OmniAddress, dstEid: EndpointId): Promise { this.logger.debug( `Checking default send library for eid ${dstEid} (${formatEid(dstEid)}) and address ${sender}` @@ -179,6 +186,7 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { } } + @AsyncRetriable() async getReceiveLibraryTimeout(receiver: OmniAddress, srcEid: EndpointId): Promise { this.logger.debug( `Getting receive library timeout for eid ${srcEid} (${formatEid(srcEid)}) and address ${receiver}` @@ -189,6 +197,7 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { return TimeoutSchema.parse({ ...timeout }) } + @AsyncRetriable() async getDefaultReceiveLibraryTimeout(eid: EndpointId): Promise { this.logger.debug(`Getting default receive library timeout for eid ${eid} (${formatEid(eid)})`) @@ -321,6 +330,7 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { return ulnSdk.hasAppUlnConfig(eid, oapp, config) } + @AsyncRetriable() isRegisteredLibrary(uln: OmniAddress): Promise { return this.contract.contract.isRegisteredLibrary(uln) } @@ -334,6 +344,7 @@ export class EndpointV2 extends OmniSDK implements IEndpointV2 { } } + @AsyncRetriable() public async quote(params: MessageParams, sender: OmniAddress): Promise { const { nativeFee, lzTokenFee } = await this.contract.contract.quote(params, sender) return { diff --git a/packages/protocol-devtools-evm/src/uln302/sdk.ts b/packages/protocol-devtools-evm/src/uln302/sdk.ts index 4f3d9b31c..e75085dc0 100644 --- a/packages/protocol-devtools-evm/src/uln302/sdk.ts +++ b/packages/protocol-devtools-evm/src/uln302/sdk.ts @@ -15,13 +15,14 @@ import { import { Uln302ExecutorConfigSchema, Uln302UlnConfigSchema } from './schema' import assert from 'assert' import { printJson } from '@layerzerolabs/io-devtools' -import { isZero } from '@layerzerolabs/devtools' +import { isZero, AsyncRetriable } from '@layerzerolabs/devtools' import { OmniSDK, addChecksum, makeZeroAddress } from '@layerzerolabs/devtools-evm' export class Uln302 extends OmniSDK implements IUln302 { /** * @see {@link IUln302.getUlnConfig} */ + @AsyncRetriable() async getUlnConfig(eid: EndpointId, address?: OmniAddress | null | undefined): Promise { this.logger.debug( `Getting ULN config for eid ${eid} (${formatEid(eid)}) and address ${makeZeroAddress(address)}` @@ -38,6 +39,7 @@ export class Uln302 extends OmniSDK implements IUln302 { /** * @see {@link IUln302.getAppUlnConfig} */ + @AsyncRetriable() async getAppUlnConfig(eid: EndpointId, address: OmniAddress): Promise { this.logger.debug( `Getting ULN config for eid ${eid} (${formatEid(eid)}) and address ${makeZeroAddress(address)}` @@ -73,6 +75,7 @@ export class Uln302 extends OmniSDK implements IUln302 { /** * @see {@link IUln302.getExecutorConfig} */ + @AsyncRetriable() async getExecutorConfig(eid: EndpointId, address?: OmniAddress | null | undefined): Promise { const config = await this.contract.contract.getExecutorConfig(makeZeroAddress(address), eid) @@ -86,6 +89,7 @@ export class Uln302 extends OmniSDK implements IUln302 { /** * @see {@link IUln302.getAppExecutorConfig} */ + @AsyncRetriable() async getAppExecutorConfig(eid: EndpointId, address: OmniAddress): Promise { const config = await this.contract.contract.executorConfigs(makeZeroAddress(address), eid) diff --git a/packages/protocol-devtools-evm/tsconfig.json b/packages/protocol-devtools-evm/tsconfig.json index acecf2754..c208b5a1e 100644 --- a/packages/protocol-devtools-evm/tsconfig.json +++ b/packages/protocol-devtools-evm/tsconfig.json @@ -3,6 +3,7 @@ "exclude": ["dist", "node_modules"], "include": ["src", "test", "*.config.ts"], "compilerOptions": { + "experimentalDecorators": true, "types": ["node", "jest"], "paths": { "@/*": ["./src/*"] diff --git a/packages/ua-devtools-evm/.swcrc b/packages/ua-devtools-evm/.swcrc new file mode 100644 index 000000000..6d5164be0 --- /dev/null +++ b/packages/ua-devtools-evm/.swcrc @@ -0,0 +1,11 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true + }, + "transform": { + "legacyDecorator": true + } + } +} \ No newline at end of file diff --git a/packages/ua-devtools-evm/src/lzapp/sdk.ts b/packages/ua-devtools-evm/src/lzapp/sdk.ts index baed9b691..248d0f8cc 100644 --- a/packages/ua-devtools-evm/src/lzapp/sdk.ts +++ b/packages/ua-devtools-evm/src/lzapp/sdk.ts @@ -6,6 +6,7 @@ import { areBytes32Equal, ignoreZero, makeBytes32, + AsyncRetriable, } from '@layerzerolabs/devtools' import { type OmniContract, parseGenericError } from '@layerzerolabs/devtools-evm' import type { EndpointId } from '@layerzerolabs/lz-definitions' @@ -16,6 +17,7 @@ export class LzApp extends OmniSDK implements ILzApp { super(contract) } + @AsyncRetriable() async getTrustedRemote(eid: EndpointId): Promise { this.logger.debug(`Getting trusted remote for eid ${eid} (${formatEid(eid)})`) diff --git a/packages/ua-devtools-evm/src/oapp/sdk.ts b/packages/ua-devtools-evm/src/oapp/sdk.ts index 072c9223b..62d459690 100644 --- a/packages/ua-devtools-evm/src/oapp/sdk.ts +++ b/packages/ua-devtools-evm/src/oapp/sdk.ts @@ -15,7 +15,7 @@ import type { EndpointId } from '@layerzerolabs/lz-definitions' import type { EndpointV2Factory, IEndpointV2 } from '@layerzerolabs/protocol-devtools' import { OmniSDK } from '@layerzerolabs/devtools-evm' import { printJson } from '@layerzerolabs/io-devtools' -import { mapError } from '@layerzerolabs/devtools' +import { mapError, AsyncRetriable } from '@layerzerolabs/devtools' import { OwnableMixin } from '@/ownable/mixin' export class OApp extends OmniSDK implements IOApp { @@ -26,6 +26,7 @@ export class OApp extends OmniSDK implements IOApp { super(contract) } + @AsyncRetriable() getOwner(): Promise { // TODO This is a quick and dirty way of applying OwnableMixin // @@ -38,6 +39,7 @@ export class OApp extends OmniSDK implements IOApp { return OwnableMixin.getOwner.call(this) } + @AsyncRetriable() hasOwner(address: string): Promise { // TODO This is a quick and dirty way of applying OwnableMixin // @@ -62,6 +64,7 @@ export class OApp extends OmniSDK implements IOApp { return OwnableMixin.setOwner.call(this, address) } + @AsyncRetriable() async getEndpointSDK(): Promise { this.logger.debug(`Getting EndpointV2 SDK`) @@ -84,6 +87,7 @@ export class OApp extends OmniSDK implements IOApp { return await this.endpointV2Factory({ address, eid: this.contract.eid }) } + @AsyncRetriable() async getPeer(eid: EndpointId): Promise { const eidLabel = `eid ${eid} (${formatEid(eid)})` @@ -110,6 +114,7 @@ export class OApp extends OmniSDK implements IOApp { } } + @AsyncRetriable() async getDelegate(): Promise { this.logger.debug(`Getting delegate`) @@ -122,6 +127,7 @@ export class OApp extends OmniSDK implements IOApp { return this.logger.debug(delegate ? `Got delegate ${delegate}` : `OApp has no delegate`), delegate } + @AsyncRetriable() async isDelegate(delegate: OmniAddress): Promise { this.logger.debug(`Checking whether ${delegate} is a delegate`) @@ -145,6 +151,7 @@ export class OApp extends OmniSDK implements IOApp { } } + @AsyncRetriable() async getEnforcedOptions(eid: EndpointId, msgType: number): Promise { const eidLabel = `eid ${eid} (${formatEid(eid)})` diff --git a/packages/ua-devtools-evm/tsconfig.json b/packages/ua-devtools-evm/tsconfig.json index f083b2ecb..c7e0734a3 100644 --- a/packages/ua-devtools-evm/tsconfig.json +++ b/packages/ua-devtools-evm/tsconfig.json @@ -3,6 +3,7 @@ "exclude": ["dist", "node_modules"], "include": ["src", "test"], "compilerOptions": { + "experimentalDecorators": true, "types": ["node", "jest"], "paths": { "@/*": ["./src/*"] diff --git a/tsconfig.json b/tsconfig.json index 234bf773e..967c9e5a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,6 @@ "resolveJsonModule": true, "types": ["node"], "skipLibCheck": true, - "stripInternal": true, - }, + "stripInternal": true + } }