@luxexchange/prices 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.depcheckrc +14 -0
- package/.eslintrc.js +20 -0
- package/README.md +3 -0
- package/package.json +3 -4
- package/project.json +2 -6
- package/src/context/PriceServiceContext.test.tsx +3 -3
- package/src/context/PriceServiceContext.tsx +9 -4
- package/src/hooks/useConnectionStatus.ts +2 -2
- package/src/hooks/usePrice.ts +13 -9
- package/src/index.ts +9 -5
- package/src/queries/priceKeys.test.ts +1 -1
- package/src/queries/tokenPriceQueryOptions.ts +33 -7
- package/src/sources/rest/RestPriceBatcher.test.ts +223 -0
- package/src/sources/rest/RestPriceBatcher.ts +110 -0
- package/src/sources/rest/constants.ts +9 -0
- package/src/sources/rest/types.ts +13 -0
- package/src/sources/websocket/messageParser.ts +2 -2
- package/src/sources/websocket/subscriptionApi.ts +9 -9
- package/src/types.ts +2 -2
- package/src/utils/tokenIdentifier.test.ts +12 -3
- package/src/utils/tokenIdentifier.ts +5 -5
- package/tsconfig.json +15 -2
- package/tsconfig.lint.json +8 -0
- package/vitest.config.ts +22 -0
package/.depcheckrc
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ignores: [
|
|
2
|
+
# Dependencies that depcheck incorrectly marks as unused
|
|
3
|
+
"typescript",
|
|
4
|
+
"@typescript/native-preview",
|
|
5
|
+
"depcheck",
|
|
6
|
+
|
|
7
|
+
# Internal packages / workspaces
|
|
8
|
+
"prices",
|
|
9
|
+
"@universe/prices",
|
|
10
|
+
|
|
11
|
+
# TODO: remove once used in tests
|
|
12
|
+
"@testing-library/react",
|
|
13
|
+
"@vitest/coverage-v8",
|
|
14
|
+
]
|
package/.eslintrc.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
extends: ['@luxamm/eslint-config/lib'],
|
|
3
|
+
parserOptions: {
|
|
4
|
+
tsconfigRootDir: __dirname,
|
|
5
|
+
},
|
|
6
|
+
overrides: [
|
|
7
|
+
{
|
|
8
|
+
files: ['*.ts', '*.tsx'],
|
|
9
|
+
rules: {
|
|
10
|
+
'no-relative-import-paths/no-relative-import-paths': [
|
|
11
|
+
'error',
|
|
12
|
+
{
|
|
13
|
+
allowSameFolder: false,
|
|
14
|
+
prefix: '@luxexchange/prices',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}
|
package/README.md
ADDED
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luxexchange/prices",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"@tanstack/react-query": "5.90.20",
|
|
6
|
-
"@
|
|
6
|
+
"@luxamm/sdk-core": "7.12.3",
|
|
7
7
|
"@luxexchange/api": "workspace:^",
|
|
8
8
|
"@luxexchange/websocket": "workspace:^",
|
|
9
9
|
"@luxfi/utilities": "workspace:^"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@testing-library/react": "16.3.0",
|
|
16
16
|
"@types/node": "22.13.1",
|
|
17
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
17
|
+
"@typescript/native-preview": "7.0.0-dev.20260311.1",
|
|
18
18
|
"@luxfi/eslint-config": "workspace:^",
|
|
19
19
|
"@vitest/coverage-v8": "3.2.1",
|
|
20
20
|
"depcheck": "1.4.7",
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"test": "nx test prices",
|
|
28
|
-
"test:coverage": "nx test:coverage prices",
|
|
29
28
|
"typecheck": "nx typecheck prices",
|
|
30
29
|
"typecheck:tsgo": "nx typecheck:tsgo prices",
|
|
31
30
|
"lint": "nx lint prices",
|
package/project.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@
|
|
2
|
+
"name": "@luxexchange/prices",
|
|
3
3
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
-
"sourceRoot": "
|
|
4
|
+
"sourceRoot": "pkgs/prices/src",
|
|
5
5
|
"projectType": "library",
|
|
6
6
|
"tags": [],
|
|
7
7
|
"targets": {
|
|
@@ -9,10 +9,6 @@
|
|
|
9
9
|
"command": "vitest run --passWithNoTests",
|
|
10
10
|
"options": { "cwd": "{projectRoot}" }
|
|
11
11
|
},
|
|
12
|
-
"test:coverage": {
|
|
13
|
-
"command": "vitest run --coverage --passWithNoTests",
|
|
14
|
-
"options": { "cwd": "{projectRoot}" }
|
|
15
|
-
},
|
|
16
12
|
"typecheck": {},
|
|
17
13
|
"typecheck:tsgo": {},
|
|
18
14
|
"lint:biome": {},
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import type { QueryClient } from '@tanstack/react-query'
|
|
4
4
|
import { renderHook } from '@testing-library/react'
|
|
5
|
-
import { PriceServiceProvider, usePricesContext } from '@
|
|
6
|
-
import type { TokenPriceMessage, TokenSubscriptionParams } from '@
|
|
7
|
-
import type { WebSocketClient } from '@
|
|
5
|
+
import { PriceServiceProvider, usePricesContext } from '@luxexchange/prices/src/context/PriceServiceContext'
|
|
6
|
+
import type { TokenPriceMessage, TokenSubscriptionParams } from '@luxexchange/prices/src/types'
|
|
7
|
+
import type { WebSocketClient } from '@luxexchange/websocket'
|
|
8
8
|
import type { ReactNode } from 'react'
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest'
|
|
10
10
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
4
|
-
import
|
|
2
|
+
import type { RestPriceBatcher } from '@luxexchange/prices/src/sources/rest/RestPriceBatcher'
|
|
3
|
+
import type { TokenPriceMessage, TokenSubscriptionParams } from '@luxexchange/prices/src/types'
|
|
4
|
+
import type { WebSocketClient } from '@luxexchange/websocket'
|
|
5
|
+
import { createContext, type ReactElement, type ReactNode, useContext, useMemo } from 'react'
|
|
5
6
|
|
|
6
7
|
interface PricesContextValue {
|
|
7
8
|
wsClient: WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']>
|
|
8
9
|
queryClient: QueryClient
|
|
10
|
+
restBatcher?: RestPriceBatcher
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const PricesContext = createContext<PricesContextValue | null>(null)
|
|
@@ -13,13 +15,16 @@ const PricesContext = createContext<PricesContextValue | null>(null)
|
|
|
13
15
|
export function PriceServiceProvider({
|
|
14
16
|
wsClient,
|
|
15
17
|
queryClient,
|
|
18
|
+
restBatcher,
|
|
16
19
|
children,
|
|
17
20
|
}: {
|
|
18
21
|
wsClient: WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']>
|
|
19
22
|
queryClient: QueryClient
|
|
23
|
+
restBatcher?: RestPriceBatcher
|
|
20
24
|
children: ReactNode
|
|
21
25
|
}): ReactElement {
|
|
22
|
-
|
|
26
|
+
const value = useMemo(() => ({ wsClient, queryClient, restBatcher }), [wsClient, queryClient, restBatcher])
|
|
27
|
+
return <PricesContext.Provider value={value}>{children}</PricesContext.Provider>
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export function usePricesContext(): PricesContextValue {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { usePricesContext } from '@
|
|
2
|
-
import type { ConnectionStatus } from '@
|
|
1
|
+
import { usePricesContext } from '@luxexchange/prices/src/context/PriceServiceContext'
|
|
2
|
+
import type { ConnectionStatus } from '@luxexchange/websocket'
|
|
3
3
|
import { useSyncExternalStore } from 'react'
|
|
4
4
|
|
|
5
5
|
/**
|
package/src/hooks/usePrice.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { queryOptions, skipToken, useQuery } from '@tanstack/react-query'
|
|
2
|
-
import { usePricesContext } from '@
|
|
3
|
-
import { priceKeys } from '@
|
|
4
|
-
import { tokenPriceQueryOptions } from '@
|
|
5
|
-
import type { TokenPriceData } from '@
|
|
1
|
+
import { queryOptions, skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { usePricesContext } from '@luxexchange/prices/src/context/PriceServiceContext'
|
|
3
|
+
import { priceKeys } from '@luxexchange/prices/src/queries/priceKeys'
|
|
4
|
+
import { tokenPriceQueryOptions } from '@luxexchange/prices/src/queries/tokenPriceQueryOptions'
|
|
5
|
+
import type { TokenPriceData } from '@luxexchange/prices/src/types'
|
|
6
6
|
import { useEffect } from 'react'
|
|
7
7
|
|
|
8
8
|
interface UsePriceOptions {
|
|
@@ -14,20 +14,23 @@ interface UsePriceOptions {
|
|
|
14
14
|
/**
|
|
15
15
|
* Hook to get the live price for a token.
|
|
16
16
|
* Reads from React Query cache and auto-subscribes via websocket.
|
|
17
|
+
* Falls back to REST polling when WS data goes stale (if restBatcher is provided).
|
|
17
18
|
*
|
|
18
19
|
* Requires a PriceServiceProvider in the tree.
|
|
19
20
|
*/
|
|
20
21
|
export function usePrice(options: UsePriceOptions): number | undefined {
|
|
21
22
|
const { chainId, address, live = true } = options
|
|
22
|
-
const { wsClient } = usePricesContext()
|
|
23
|
+
const { wsClient, restBatcher } = usePricesContext()
|
|
24
|
+
const queryClient = useQueryClient()
|
|
23
25
|
|
|
24
26
|
const enabled = chainId !== undefined && !!address
|
|
25
27
|
|
|
26
28
|
// Data is populated externally via queryClient.setQueryData from WS messages.
|
|
29
|
+
// When restBatcher is provided, queryFn fires as a fallback when WS data goes stale.
|
|
27
30
|
const { data } = useQuery(
|
|
28
31
|
enabled
|
|
29
|
-
? tokenPriceQueryOptions(chainId, address)
|
|
30
|
-
: queryOptions<TokenPriceData>({ queryKey: priceKeys.all, queryFn: skipToken, enabled: false }),
|
|
32
|
+
? tokenPriceQueryOptions({ chainId, address, restBatcher, queryClient })
|
|
33
|
+
: queryOptions<TokenPriceData | null>({ queryKey: priceKeys.all, queryFn: skipToken, enabled: false }),
|
|
31
34
|
)
|
|
32
35
|
|
|
33
36
|
useEffect(() => {
|
|
@@ -40,5 +43,6 @@ export function usePrice(options: UsePriceOptions): number | undefined {
|
|
|
40
43
|
})
|
|
41
44
|
}, [enabled, live, chainId, address, wsClient])
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
const price: number | undefined = enabled ? (data?.price ?? undefined) : undefined
|
|
47
|
+
return price
|
|
44
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,9 @@ export {
|
|
|
5
5
|
isRawTokenPriceMessage,
|
|
6
6
|
parseConnectionMessage,
|
|
7
7
|
parseTokenPriceMessage,
|
|
8
|
-
} from '@
|
|
8
|
+
} from '@luxexchange/prices/src/sources/websocket/messageParser'
|
|
9
9
|
// Internals (testing / custom setups)
|
|
10
|
-
export { createPriceSubscriptionHandler } from '@
|
|
10
|
+
export { createPriceSubscriptionHandler } from '@luxexchange/prices/src/sources/websocket/subscriptionApi'
|
|
11
11
|
export type {
|
|
12
12
|
ConnectionEstablishedMessage,
|
|
13
13
|
Logger,
|
|
@@ -19,7 +19,7 @@ export type {
|
|
|
19
19
|
TokenPriceData,
|
|
20
20
|
TokenPriceMessage,
|
|
21
21
|
TokenSubscriptionParams,
|
|
22
|
-
} from '@
|
|
22
|
+
} from '@luxexchange/prices/src/types'
|
|
23
23
|
export {
|
|
24
24
|
createPriceKey,
|
|
25
25
|
createPriceKeyFromToken,
|
|
@@ -29,8 +29,8 @@ export {
|
|
|
29
29
|
normalizeToken,
|
|
30
30
|
parsePriceKey,
|
|
31
31
|
toSubscriptionParams,
|
|
32
|
-
} from '@
|
|
33
|
-
export type { ConnectionStatus } from '@
|
|
32
|
+
} from '@luxexchange/prices/src/utils/tokenIdentifier'
|
|
33
|
+
export type { ConnectionStatus } from '@luxexchange/websocket'
|
|
34
34
|
export { PriceServiceProvider, usePricesContext } from './context/PriceServiceContext'
|
|
35
35
|
export { useConnectionStatus } from './hooks/useConnectionStatus'
|
|
36
36
|
// Consumer hooks
|
|
@@ -39,3 +39,7 @@ export { usePrice, usePrice as useLivePrice } from './hooks/usePrice'
|
|
|
39
39
|
// Query utilities (advanced / UDL)
|
|
40
40
|
export { priceKeys } from './queries/priceKeys'
|
|
41
41
|
export { tokenPriceQueryOptions } from './queries/tokenPriceQueryOptions'
|
|
42
|
+
export { REST_POLL_INTERVAL_MS } from './sources/rest/constants'
|
|
43
|
+
// REST fallback
|
|
44
|
+
export { RestPriceBatcher } from './sources/rest/RestPriceBatcher'
|
|
45
|
+
export type { RestPriceClient } from './sources/rest/types'
|
|
@@ -1,15 +1,41 @@
|
|
|
1
|
-
import { queryOptions, skipToken } from '@tanstack/react-query'
|
|
2
|
-
import { priceKeys } from '@
|
|
3
|
-
import
|
|
1
|
+
import { type QueryClient, queryOptions, skipToken } from '@tanstack/react-query'
|
|
2
|
+
import { priceKeys } from '@luxexchange/prices/src/queries/priceKeys'
|
|
3
|
+
import { REST_POLL_INTERVAL_MS } from '@luxexchange/prices/src/sources/rest/constants'
|
|
4
|
+
import type { RestPriceBatcher } from '@luxexchange/prices/src/sources/rest/RestPriceBatcher'
|
|
5
|
+
import type { TokenPriceData } from '@luxexchange/prices/src/types'
|
|
6
|
+
|
|
7
|
+
export interface TokenPriceQueryOptionsParams {
|
|
8
|
+
chainId: number
|
|
9
|
+
address: string
|
|
10
|
+
restBatcher?: RestPriceBatcher
|
|
11
|
+
queryClient?: QueryClient
|
|
12
|
+
}
|
|
4
13
|
|
|
5
14
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
6
|
-
export function tokenPriceQueryOptions(chainId
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
export function tokenPriceQueryOptions({ chainId, address, restBatcher, queryClient }: TokenPriceQueryOptionsParams) {
|
|
16
|
+
const key = priceKeys.token(chainId, address)
|
|
17
|
+
return queryOptions<TokenPriceData | null>({
|
|
18
|
+
queryKey: key,
|
|
19
|
+
queryFn: restBatcher
|
|
20
|
+
? async (): Promise<TokenPriceData | null> => {
|
|
21
|
+
const fresh = await restBatcher.fetch({ chainId, address: address.toLowerCase() })
|
|
22
|
+
if (!fresh) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
// Guard against REST responses overwriting newer WebSocket data.
|
|
26
|
+
// refetchInterval fires independently of staleTime, so without this
|
|
27
|
+
// check a 30s-old REST response could clobber a sub-second WS update.
|
|
28
|
+
const existing = queryClient?.getQueryData<TokenPriceData>(key)
|
|
29
|
+
if (existing && existing.timestamp >= fresh.timestamp) {
|
|
30
|
+
return existing
|
|
31
|
+
}
|
|
32
|
+
return fresh
|
|
33
|
+
}
|
|
34
|
+
: skipToken,
|
|
10
35
|
staleTime: Infinity,
|
|
11
36
|
refetchOnWindowFocus: false,
|
|
12
37
|
refetchOnMount: false,
|
|
13
38
|
structuralSharing: false,
|
|
39
|
+
refetchInterval: restBatcher ? REST_POLL_INTERVAL_MS : false,
|
|
14
40
|
})
|
|
15
41
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { BATCH_DELAY_MS, MAX_BATCH_SIZE } from '@luxexchange/prices/src/sources/rest/constants'
|
|
2
|
+
import { RestPriceBatcher } from '@luxexchange/prices/src/sources/rest/RestPriceBatcher'
|
|
3
|
+
import type { RestPriceClient } from '@luxexchange/prices/src/sources/rest/types'
|
|
4
|
+
import type { TokenPriceData } from '@luxexchange/prices/src/types'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
function createMockClient(
|
|
8
|
+
handler: (tokens: { chainId: number; address: string }[]) => Promise<Map<string, TokenPriceData>>,
|
|
9
|
+
): RestPriceClient {
|
|
10
|
+
return { getTokenPrices: vi.fn(handler) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function priceData(price: number, timestamp = Date.now()): TokenPriceData {
|
|
14
|
+
return { price, timestamp }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('RestPriceBatcher', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('batches multiple fetch calls within the batching window', async () => {
|
|
27
|
+
const client = createMockClient(async (tokens) => {
|
|
28
|
+
const result = new Map<string, TokenPriceData>()
|
|
29
|
+
for (const t of tokens) {
|
|
30
|
+
result.set(`${t.chainId}-${t.address}`, priceData(100 + t.chainId))
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const batcher = new RestPriceBatcher(client)
|
|
36
|
+
|
|
37
|
+
// Fire multiple fetches synchronously (same batching window)
|
|
38
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
39
|
+
const p2 = batcher.fetch({ chainId: 42161, address: '0xbbb' })
|
|
40
|
+
|
|
41
|
+
// Advance past the batch delay to trigger flush
|
|
42
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
43
|
+
|
|
44
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
45
|
+
|
|
46
|
+
expect(r1).toEqual(priceData(101, r1!.timestamp))
|
|
47
|
+
expect(r2).toEqual(priceData(42261, r2!.timestamp))
|
|
48
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(1)
|
|
49
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([
|
|
50
|
+
{ chainId: 1, address: '0xaaa' },
|
|
51
|
+
{ chainId: 42161, address: '0xbbb' },
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('deduplicates tokens within the same batch', async () => {
|
|
56
|
+
const client = createMockClient(async (tokens) => {
|
|
57
|
+
const result = new Map<string, TokenPriceData>()
|
|
58
|
+
for (const t of tokens) {
|
|
59
|
+
result.set(`${t.chainId}-${t.address}`, priceData(50))
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const batcher = new RestPriceBatcher(client)
|
|
65
|
+
|
|
66
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xAAA' })
|
|
67
|
+
const p2 = batcher.fetch({ chainId: 1, address: '0xaaa' }) // Same after lowercase
|
|
68
|
+
|
|
69
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
70
|
+
|
|
71
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
72
|
+
|
|
73
|
+
expect(r1).toEqual(priceData(50, r1!.timestamp))
|
|
74
|
+
expect(r2).toEqual(priceData(50, r2!.timestamp))
|
|
75
|
+
// Only 1 unique token sent to the client
|
|
76
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([{ chainId: 1, address: '0xaaa' }])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('chunks large batches to respect MAX_BATCH_SIZE', async () => {
|
|
80
|
+
const client = createMockClient(async (tokens) => {
|
|
81
|
+
const result = new Map<string, TokenPriceData>()
|
|
82
|
+
for (const t of tokens) {
|
|
83
|
+
result.set(`${t.chainId}-${t.address}`, priceData(1))
|
|
84
|
+
}
|
|
85
|
+
return result
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const batcher = new RestPriceBatcher(client)
|
|
89
|
+
|
|
90
|
+
// Create MAX_BATCH_SIZE + 10 unique tokens
|
|
91
|
+
const count = MAX_BATCH_SIZE + 10
|
|
92
|
+
const promises = Array.from({ length: count }, (_, i) =>
|
|
93
|
+
batcher.fetch({ chainId: 1, address: `0x${i.toString(16).padStart(40, '0')}` }),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
97
|
+
|
|
98
|
+
const results = await Promise.all(promises)
|
|
99
|
+
|
|
100
|
+
expect(results).toHaveLength(count)
|
|
101
|
+
expect(results.every((r) => r !== undefined)).toBe(true)
|
|
102
|
+
// Should have been split into 2 REST calls
|
|
103
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('propagates errors to all pending promises', async () => {
|
|
107
|
+
const error = new Error('network failure')
|
|
108
|
+
const client = createMockClient(async () => {
|
|
109
|
+
throw error
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const batcher = new RestPriceBatcher(client)
|
|
113
|
+
|
|
114
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
115
|
+
const p2 = batcher.fetch({ chainId: 42161, address: '0xbbb' })
|
|
116
|
+
|
|
117
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
118
|
+
|
|
119
|
+
await expect(p1).rejects.toThrow('network failure')
|
|
120
|
+
await expect(p2).rejects.toThrow('network failure')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns undefined for tokens not in REST response', async () => {
|
|
124
|
+
const client = createMockClient(async () => {
|
|
125
|
+
// Return empty map (no prices available)
|
|
126
|
+
return new Map<string, TokenPriceData>()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const batcher = new RestPriceBatcher(client)
|
|
130
|
+
|
|
131
|
+
const promise = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
132
|
+
|
|
133
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
134
|
+
|
|
135
|
+
const result = await promise
|
|
136
|
+
|
|
137
|
+
expect(result).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('processes sequential batches independently', async () => {
|
|
141
|
+
let callCount = 0
|
|
142
|
+
const client = createMockClient(async (tokens) => {
|
|
143
|
+
callCount++
|
|
144
|
+
const result = new Map<string, TokenPriceData>()
|
|
145
|
+
for (const t of tokens) {
|
|
146
|
+
result.set(`${t.chainId}-${t.address}`, priceData(callCount * 100))
|
|
147
|
+
}
|
|
148
|
+
return result
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const batcher = new RestPriceBatcher(client)
|
|
152
|
+
|
|
153
|
+
// First batch
|
|
154
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
155
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
156
|
+
const r1 = await p1
|
|
157
|
+
expect(r1?.price).toBe(100)
|
|
158
|
+
|
|
159
|
+
// Second batch (new timer window)
|
|
160
|
+
const p2 = batcher.fetch({ chainId: 1, address: '0xbbb' })
|
|
161
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
162
|
+
const r2 = await p2
|
|
163
|
+
expect(r2?.price).toBe(200)
|
|
164
|
+
|
|
165
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(2)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('lowercases addresses for deduplication', async () => {
|
|
169
|
+
const client = createMockClient(async (tokens) => {
|
|
170
|
+
const result = new Map<string, TokenPriceData>()
|
|
171
|
+
for (const t of tokens) {
|
|
172
|
+
result.set(`${t.chainId}-${t.address}`, priceData(42))
|
|
173
|
+
}
|
|
174
|
+
return result
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const batcher = new RestPriceBatcher(client)
|
|
178
|
+
|
|
179
|
+
const promise = batcher.fetch({ chainId: 1, address: '0xAbCdEf' })
|
|
180
|
+
|
|
181
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
182
|
+
|
|
183
|
+
const result = await promise
|
|
184
|
+
|
|
185
|
+
expect(result?.price).toBe(42)
|
|
186
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([{ chainId: 1, address: '0xabcdef' }])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('batches requests arriving in separate macrotasks within the delay window', async () => {
|
|
190
|
+
const client = createMockClient(async (tokens) => {
|
|
191
|
+
const result = new Map<string, TokenPriceData>()
|
|
192
|
+
for (const t of tokens) {
|
|
193
|
+
result.set(`${t.chainId}-${t.address}`, priceData(99))
|
|
194
|
+
}
|
|
195
|
+
return result
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const batcher = new RestPriceBatcher(client)
|
|
199
|
+
|
|
200
|
+
// First request starts the timer
|
|
201
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
202
|
+
|
|
203
|
+
// Advance partway through the delay (simulating a second macrotask arriving)
|
|
204
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS / 2)
|
|
205
|
+
|
|
206
|
+
// Second request arrives before the timer fires
|
|
207
|
+
const p2 = batcher.fetch({ chainId: 10, address: '0xbbb' })
|
|
208
|
+
|
|
209
|
+
// Now advance past the delay to flush
|
|
210
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
211
|
+
|
|
212
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
213
|
+
|
|
214
|
+
expect(r1?.price).toBe(99)
|
|
215
|
+
expect(r2?.price).toBe(99)
|
|
216
|
+
// Both should be in a single batch
|
|
217
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(1)
|
|
218
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([
|
|
219
|
+
{ chainId: 1, address: '0xaaa' },
|
|
220
|
+
{ chainId: 10, address: '0xbbb' },
|
|
221
|
+
])
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { BATCH_DELAY_MS, MAX_BATCH_SIZE } from '@luxexchange/prices/src/sources/rest/constants'
|
|
2
|
+
import type { RestPriceClient } from '@luxexchange/prices/src/sources/rest/types'
|
|
3
|
+
import type { TokenIdentifier, TokenPriceData } from '@luxexchange/prices/src/types'
|
|
4
|
+
import { createPriceKey } from '@luxexchange/prices/src/utils/tokenIdentifier'
|
|
5
|
+
|
|
6
|
+
interface PendingRequest {
|
|
7
|
+
token: TokenIdentifier
|
|
8
|
+
resolve: (value: TokenPriceData | undefined) => void
|
|
9
|
+
reject: (error: unknown) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Timer-based batcher for REST price fetches.
|
|
14
|
+
*
|
|
15
|
+
* Individual `fetch(token)` calls are coalesced into a single batch REST
|
|
16
|
+
* request within a short delay window (BATCH_DELAY_MS ≈ one frame). This
|
|
17
|
+
* ensures requests from separate macrotasks (e.g. React Query refetchInterval
|
|
18
|
+
* callbacks) are grouped together. Duplicate tokens share the same result.
|
|
19
|
+
* Batches exceeding MAX_BATCH_SIZE are chunked into parallel requests.
|
|
20
|
+
*/
|
|
21
|
+
export class RestPriceBatcher {
|
|
22
|
+
private readonly client: RestPriceClient
|
|
23
|
+
private pending: PendingRequest[] = []
|
|
24
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
25
|
+
|
|
26
|
+
constructor(client: RestPriceClient) {
|
|
27
|
+
this.client = client
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Request a price for a single token. Multiple calls within the same
|
|
32
|
+
* batching window (BATCH_DELAY_MS) are batched into one REST request.
|
|
33
|
+
*
|
|
34
|
+
* @returns The price data, or undefined if the token has no price.
|
|
35
|
+
*/
|
|
36
|
+
fetch(token: TokenIdentifier): Promise<TokenPriceData | undefined> {
|
|
37
|
+
return new Promise<TokenPriceData | undefined>((resolve, reject) => {
|
|
38
|
+
this.pending.push({
|
|
39
|
+
token: { chainId: token.chainId, address: token.address.toLowerCase() },
|
|
40
|
+
resolve,
|
|
41
|
+
reject,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (this.flushTimer === null) {
|
|
45
|
+
this.flushTimer = setTimeout(() => this.flush(), BATCH_DELAY_MS)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async flush(): Promise<void> {
|
|
51
|
+
const batch = this.pending
|
|
52
|
+
this.pending = []
|
|
53
|
+
this.flushTimer = null
|
|
54
|
+
|
|
55
|
+
if (batch.length === 0) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Group resolvers by price key (deduplication)
|
|
60
|
+
const resolversByKey = new Map<string, PendingRequest[]>()
|
|
61
|
+
const uniqueTokens = new Map<string, TokenIdentifier>()
|
|
62
|
+
|
|
63
|
+
for (const request of batch) {
|
|
64
|
+
const key = createPriceKey(request.token.chainId, request.token.address)
|
|
65
|
+
const existing = resolversByKey.get(key)
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.push(request)
|
|
68
|
+
} else {
|
|
69
|
+
resolversByKey.set(key, [request])
|
|
70
|
+
uniqueTokens.set(key, request.token)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokens = Array.from(uniqueTokens.values())
|
|
75
|
+
|
|
76
|
+
// Chunk into batches of MAX_BATCH_SIZE
|
|
77
|
+
const chunks: TokenIdentifier[][] = []
|
|
78
|
+
for (let i = 0; i < tokens.length; i += MAX_BATCH_SIZE) {
|
|
79
|
+
chunks.push(tokens.slice(i, i + MAX_BATCH_SIZE))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Fetch all chunks in parallel
|
|
84
|
+
const results = await Promise.all(chunks.map((chunk) => this.client.getTokenPrices(chunk)))
|
|
85
|
+
|
|
86
|
+
// Merge all chunk results
|
|
87
|
+
const merged = new Map<string, TokenPriceData>()
|
|
88
|
+
for (const chunkResult of results) {
|
|
89
|
+
for (const [key, value] of chunkResult) {
|
|
90
|
+
merged.set(key, value)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve all pending requests
|
|
95
|
+
for (const [key, requests] of resolversByKey) {
|
|
96
|
+
const data = merged.get(key)
|
|
97
|
+
for (const request of requests) {
|
|
98
|
+
request.resolve(data)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// On error, reject all pending promises (React Query retries individually)
|
|
103
|
+
for (const requests of resolversByKey.values()) {
|
|
104
|
+
for (const request of requests) {
|
|
105
|
+
request.reject(error)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** How often to poll REST as a fallback for fresh prices */
|
|
2
|
+
export const REST_POLL_INTERVAL_MS = 30_000
|
|
3
|
+
|
|
4
|
+
/** Maximum tokens per REST request (backend limit) */
|
|
5
|
+
export const MAX_BATCH_SIZE = 100
|
|
6
|
+
|
|
7
|
+
/** Delay before flushing a batch (~one frame). Allows requests from separate
|
|
8
|
+
* macrotasks (e.g. React Query refetchInterval callbacks) to be grouped. */
|
|
9
|
+
export const BATCH_DELAY_MS = 16
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TokenIdentifier, TokenPriceData } from '@luxexchange/prices/src/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract for fetching token prices via REST.
|
|
5
|
+
* Implementations handle the actual transport (ConnectRPC, fetch, etc.).
|
|
6
|
+
*/
|
|
7
|
+
export interface RestPriceClient {
|
|
8
|
+
/**
|
|
9
|
+
* Fetches prices for a batch of tokens.
|
|
10
|
+
* @returns Map keyed by price key ("chainId-address"), missing tokens omitted.
|
|
11
|
+
*/
|
|
12
|
+
getTokenPrices(tokens: TokenIdentifier[]): Promise<Map<string, TokenPriceData>>
|
|
13
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ConnectionEstablishedMessage, RawTokenPriceMessage, TokenPriceMessage } from '@
|
|
2
|
-
import { createPriceKey } from '@
|
|
1
|
+
import type { ConnectionEstablishedMessage, RawTokenPriceMessage, TokenPriceMessage } from '@luxexchange/prices/src/types'
|
|
2
|
+
import { createPriceKey } from '@luxexchange/prices/src/utils/tokenIdentifier'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Type guard for RawTokenPriceMessage
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { FetchClient } from '@
|
|
2
|
-
import type { TokenSubscriptionParams } from '@
|
|
3
|
-
import type { SubscriptionHandler } from '@
|
|
1
|
+
import type { FetchClient } from '@luxexchange/api'
|
|
2
|
+
import type { TokenSubscriptionParams } from '@luxexchange/prices/src/types'
|
|
3
|
+
import type { SubscriptionHandler } from '@luxexchange/websocket'
|
|
4
4
|
|
|
5
5
|
const EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE = 'EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE'
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ export interface SubscriptionApiOptions {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Creates a subscription handler for token price subscriptions.
|
|
14
|
-
* This implements the SubscriptionHandler interface from @
|
|
14
|
+
* This implements the SubscriptionHandler interface from @luxexchange/websocket.
|
|
15
15
|
*/
|
|
16
16
|
export function createPriceSubscriptionHandler(
|
|
17
17
|
options: SubscriptionApiOptions,
|
|
@@ -20,7 +20,7 @@ export function createPriceSubscriptionHandler(
|
|
|
20
20
|
|
|
21
21
|
async function subscribe(connectionId: string, params: TokenSubscriptionParams): Promise<void> {
|
|
22
22
|
try {
|
|
23
|
-
await client.post('/
|
|
23
|
+
await client.post('/uniswap.notificationservice.v1.EventSubscriptionService/Subscribe', {
|
|
24
24
|
body: JSON.stringify({
|
|
25
25
|
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
26
26
|
connectionId,
|
|
@@ -34,7 +34,7 @@ export function createPriceSubscriptionHandler(
|
|
|
34
34
|
|
|
35
35
|
async function unsubscribe(connectionId: string, params: TokenSubscriptionParams): Promise<void> {
|
|
36
36
|
try {
|
|
37
|
-
await client.post('/
|
|
37
|
+
await client.post('/uniswap.notificationservice.v1.EventSubscriptionService/Unsubscribe', {
|
|
38
38
|
body: JSON.stringify({
|
|
39
39
|
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
40
40
|
connectionId,
|
|
@@ -52,7 +52,7 @@ export function createPriceSubscriptionHandler(
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
try {
|
|
55
|
-
await client.post('/
|
|
55
|
+
await client.post('/uniswap.notificationservice.v1.EventSubscriptionService/Subscribe', {
|
|
56
56
|
body: JSON.stringify({
|
|
57
57
|
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
58
58
|
connectionId,
|
|
@@ -70,7 +70,7 @@ export function createPriceSubscriptionHandler(
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
try {
|
|
73
|
-
await client.post('/
|
|
73
|
+
await client.post('/uniswap.notificationservice.v1.EventSubscriptionService/Unsubscribe', {
|
|
74
74
|
body: JSON.stringify({
|
|
75
75
|
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
76
76
|
connectionId,
|
|
@@ -84,7 +84,7 @@ export function createPriceSubscriptionHandler(
|
|
|
84
84
|
|
|
85
85
|
async function refreshSession(connectionId: string): Promise<void> {
|
|
86
86
|
try {
|
|
87
|
-
await client.post('/
|
|
87
|
+
await client.post('/uniswap.notificationservice.v1.EventSubscriptionService/RefreshSession', {
|
|
88
88
|
body: JSON.stringify({
|
|
89
89
|
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
90
90
|
connectionId,
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Currency } from '@
|
|
1
|
+
import type { Currency } from '@luxamm/sdk-core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A token identifier with chain and address.
|
|
@@ -28,7 +28,7 @@ export interface TokenPriceData {
|
|
|
28
28
|
/**
|
|
29
29
|
* Key used to identify a token price in the store.
|
|
30
30
|
* Format: "chainId-address" (e.g., "1-0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
|
31
|
-
* Matches CurrencyId convention from
|
|
31
|
+
* Matches CurrencyId convention from uniswap/src/utils/currencyId.ts
|
|
32
32
|
*/
|
|
33
33
|
export type PriceKey = string
|
|
34
34
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Token } from '@
|
|
2
|
-
import type { TokenIdentifier } from '@
|
|
1
|
+
import { Ether, Token } from '@luxamm/sdk-core'
|
|
2
|
+
import type { TokenIdentifier } from '@luxexchange/prices'
|
|
3
3
|
import {
|
|
4
4
|
createPriceKey,
|
|
5
5
|
createPriceKeyFromToken,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
normalizeToken,
|
|
10
10
|
parsePriceKey,
|
|
11
11
|
toSubscriptionParams,
|
|
12
|
-
} from '@
|
|
12
|
+
} from '@luxexchange/prices'
|
|
13
13
|
import { describe, expect, it } from 'vitest'
|
|
14
14
|
|
|
15
15
|
const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
|
|
@@ -50,6 +50,15 @@ describe('tokenIdentifier utilities', () => {
|
|
|
50
50
|
})
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
+
it('normalizes native currency to zero address', () => {
|
|
54
|
+
const eth = Ether.onChain(1)
|
|
55
|
+
const result = normalizeToken(eth)
|
|
56
|
+
expect(result).toEqual({
|
|
57
|
+
chainId: 1,
|
|
58
|
+
address: '0x0000000000000000000000000000000000000000',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
53
62
|
it('normalizes Token instance', () => {
|
|
54
63
|
const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
|
|
55
64
|
const result = normalizeToken(token)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { Currency, Token } from '@
|
|
2
|
-
import type { PriceKey, TokenIdentifier, TokenInput, TokenSubscriptionParams } from '@
|
|
1
|
+
import type { Currency, Token } from '@luxamm/sdk-core'
|
|
2
|
+
import type { PriceKey, TokenIdentifier, TokenInput, TokenSubscriptionParams } from '@luxexchange/prices'
|
|
3
3
|
import { isEVMAddress } from 'utilities/src/addresses/evm/evm'
|
|
4
4
|
|
|
5
5
|
/** Address that represents native currencies on ETH, Arbitrum, etc. */
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Type guard to check if input is a Currency object (from @
|
|
9
|
+
* Type guard to check if input is a Currency object (from @luxamm/sdk-core).
|
|
10
10
|
* Currency objects have isNative and isToken properties from the SDK.
|
|
11
11
|
*/
|
|
12
12
|
export function isCurrency(token: TokenInput): token is Currency {
|
|
@@ -50,7 +50,7 @@ export function normalizeToken(token: TokenInput): TokenIdentifier {
|
|
|
50
50
|
if (currency.isNative) {
|
|
51
51
|
return {
|
|
52
52
|
chainId: currency.chainId,
|
|
53
|
-
address:
|
|
53
|
+
address: DEFAULT_NATIVE_ADDRESS,
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
package/tsconfig.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"extends": "../../config/tsconfig/app.json",
|
|
3
|
-
"include": [
|
|
4
|
-
|
|
3
|
+
"include": [
|
|
4
|
+
"src/**/*.ts",
|
|
5
|
+
"src/**/*.tsx",
|
|
6
|
+
"src/**/*.json",
|
|
7
|
+
"src/global.d.ts"
|
|
8
|
+
],
|
|
9
|
+
"exclude": [
|
|
10
|
+
"src/**/*.spec.ts",
|
|
11
|
+
"src/**/*.spec.tsx",
|
|
12
|
+
"src/**/*.test.ts",
|
|
13
|
+
"src/**/*.test.tsx"
|
|
14
|
+
],
|
|
5
15
|
"compilerOptions": {
|
|
6
16
|
"noEmit": false,
|
|
7
17
|
"emitDeclarationOnly": true,
|
|
@@ -17,6 +27,9 @@
|
|
|
17
27
|
},
|
|
18
28
|
{
|
|
19
29
|
"path": "../api"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "../eslint-config"
|
|
20
33
|
}
|
|
21
34
|
]
|
|
22
35
|
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
server: {
|
|
7
|
+
deps: {
|
|
8
|
+
inline: ['@tanstack/react-query', 'partysocket'],
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
coverage: {
|
|
12
|
+
exclude: [
|
|
13
|
+
'**/__generated__/**',
|
|
14
|
+
'**/node_modules/**',
|
|
15
|
+
'**/dist/**',
|
|
16
|
+
'**/*.config.*',
|
|
17
|
+
'**/scripts/**',
|
|
18
|
+
'**/.eslintrc.*',
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|