@linktr.ee/messaging-react 1.23.0-rc-1772427007 → 1.24.0

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.
@@ -1,244 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
-
3
- import {
4
- createQueryChannelsManager,
5
- getRetryDelayMsFromRateLimitError,
6
- isRateLimitError,
7
- } from './queryChannelsManager'
8
-
9
- type MockChannel = { id: string }
10
-
11
- describe('queryChannelsManager', () => {
12
- it('dedupes in-flight requests for the same request key', async () => {
13
- const resolverRef: { current: ((value: MockChannel[]) => void) | null } = {
14
- current: null,
15
- }
16
-
17
- const queryChannels = vi.fn(
18
- () =>
19
- new Promise<MockChannel[]>((resolve) => {
20
- resolverRef.current = resolve
21
- })
22
- )
23
-
24
- const manager = createQueryChannelsManager({
25
- client: { queryChannels } as never,
26
- wait: async () => {},
27
- })
28
-
29
- const firstPromise = manager.query({
30
- requestKey: 'direct:viewer:participant',
31
- filters: {
32
- type: 'messaging',
33
- },
34
- options: { limit: 1 },
35
- })
36
- const secondPromise = manager.query({
37
- requestKey: 'direct:viewer:participant',
38
- filters: {
39
- type: 'messaging',
40
- },
41
- options: { limit: 1 },
42
- })
43
-
44
- expect(queryChannels).toHaveBeenCalledTimes(1)
45
-
46
- const resolveQuery = resolverRef.current
47
- expect(resolveQuery).toBeTypeOf('function')
48
- if (!resolveQuery) {
49
- throw new Error('Expected in-flight query resolver to be set')
50
- }
51
- resolveQuery([{ id: 'channel-1' }])
52
-
53
- await expect(firstPromise).resolves.toEqual([{ id: 'channel-1' }])
54
- await expect(secondPromise).resolves.toEqual([{ id: 'channel-1' }])
55
- })
56
-
57
- it('does not dedupe requests that use different request keys', async () => {
58
- const queryChannels = vi.fn().mockResolvedValue([{ id: 'channel-1' }])
59
-
60
- const manager = createQueryChannelsManager({
61
- client: { queryChannels } as never,
62
- wait: async () => {},
63
- })
64
-
65
- await manager.query({
66
- requestKey: 'sync:user',
67
- filters: {
68
- type: 'messaging',
69
- },
70
- options: { limit: 1 },
71
- })
72
-
73
- await manager.query({
74
- requestKey: 'sync:user:refresh:1',
75
- filters: {
76
- type: 'messaging',
77
- },
78
- options: { limit: 1 },
79
- })
80
-
81
- expect(queryChannels).toHaveBeenCalledTimes(2)
82
- })
83
-
84
- it('retries with retry-after header when queryChannels returns a 429 response', async () => {
85
- const wait = vi.fn(async () => {})
86
- const queryChannels = vi
87
- .fn()
88
- .mockRejectedValueOnce({
89
- response: {
90
- status: 429,
91
- headers: {
92
- 'retry-after': '2',
93
- },
94
- },
95
- })
96
- .mockResolvedValueOnce([{ id: 'channel-1' }])
97
-
98
- const manager = createQueryChannelsManager({
99
- client: { queryChannels } as never,
100
- wait,
101
- maxRetries: 2,
102
- })
103
-
104
- const channels = await manager.query({
105
- requestKey: 'direct:viewer:participant',
106
- filters: {
107
- type: 'messaging',
108
- },
109
- options: { limit: 1 },
110
- })
111
-
112
- expect(wait).toHaveBeenCalledWith(2000)
113
- expect(queryChannels).toHaveBeenCalledTimes(2)
114
- expect(channels).toEqual([{ id: 'channel-1' }])
115
- })
116
-
117
- it('falls back to x-ratelimit-reset header when retry-after is absent', () => {
118
- const delay = getRetryDelayMsFromRateLimitError(
119
- {
120
- response: {
121
- status: 429,
122
- headers: {
123
- 'x-ratelimit-reset': '12',
124
- },
125
- },
126
- },
127
- 0,
128
- 10_000
129
- )
130
-
131
- expect(delay).toBe(2_000)
132
- })
133
-
134
- it('retries for stream error code 9 even if status is unavailable', async () => {
135
- const wait = vi.fn(async () => {})
136
- const queryChannels = vi
137
- .fn()
138
- .mockRejectedValueOnce({
139
- code: 9,
140
- message:
141
- 'StreamChat error code 9: QueryChannels failed with error: "Too many requests"',
142
- })
143
- .mockResolvedValueOnce([{ id: 'channel-1' }])
144
-
145
- const manager = createQueryChannelsManager({
146
- client: { queryChannels } as never,
147
- wait,
148
- maxRetries: 2,
149
- })
150
-
151
- const channels = await manager.query({
152
- requestKey: 'direct:viewer:participant',
153
- filters: {
154
- type: 'messaging',
155
- },
156
- options: { limit: 1 },
157
- })
158
-
159
- expect(wait).toHaveBeenCalledTimes(1)
160
- expect(queryChannels).toHaveBeenCalledTimes(2)
161
- expect(channels).toEqual([{ id: 'channel-1' }])
162
- })
163
-
164
- it('does not retry non-rate-limit errors', async () => {
165
- const wait = vi.fn(async () => {})
166
- const queryChannels = vi
167
- .fn()
168
- .mockRejectedValueOnce(new Error('network down'))
169
-
170
- const manager = createQueryChannelsManager({
171
- client: { queryChannels } as never,
172
- wait,
173
- maxRetries: 2,
174
- })
175
-
176
- await expect(
177
- manager.query({
178
- requestKey: 'direct:viewer:participant',
179
- filters: {
180
- type: 'messaging',
181
- },
182
- options: { limit: 1 },
183
- })
184
- ).rejects.toThrow('network down')
185
-
186
- expect(wait).not.toHaveBeenCalled()
187
- expect(queryChannels).toHaveBeenCalledTimes(1)
188
- })
189
-
190
- it('throws after max retries are exhausted', async () => {
191
- const wait = vi.fn(async () => {})
192
- const rateLimitError = {
193
- response: {
194
- status: 429,
195
- headers: {},
196
- },
197
- }
198
-
199
- const queryChannels = vi.fn().mockRejectedValue(rateLimitError)
200
-
201
- const manager = createQueryChannelsManager({
202
- client: { queryChannels } as never,
203
- wait,
204
- maxRetries: 1,
205
- })
206
-
207
- await expect(
208
- manager.query({
209
- requestKey: 'direct:viewer:participant',
210
- filters: {
211
- type: 'messaging',
212
- },
213
- options: { limit: 1 },
214
- })
215
- ).rejects.toBe(rateLimitError)
216
-
217
- expect(wait).toHaveBeenCalledTimes(1)
218
- expect(queryChannels).toHaveBeenCalledTimes(2)
219
- })
220
- })
221
-
222
- describe('isRateLimitError', () => {
223
- it('returns true for response status 429', () => {
224
- expect(
225
- isRateLimitError({
226
- response: {
227
- status: 429,
228
- },
229
- })
230
- ).toBe(true)
231
- })
232
-
233
- it('returns true for stream code 9', () => {
234
- expect(
235
- isRateLimitError({
236
- code: 9,
237
- })
238
- ).toBe(true)
239
- })
240
-
241
- it('returns false for non-rate-limit errors', () => {
242
- expect(isRateLimitError(new Error('oops'))).toBe(false)
243
- })
244
- })
@@ -1,174 +0,0 @@
1
- import type { StreamChat } from 'stream-chat'
2
-
3
- type QueryChannelsFilters = Parameters<StreamChat['queryChannels']>[0]
4
- type QueryChannelsSort = Parameters<StreamChat['queryChannels']>[1]
5
- type QueryChannelsOptions = Parameters<StreamChat['queryChannels']>[2]
6
- type QueryChannelsResult = Awaited<ReturnType<StreamChat['queryChannels']>>
7
-
8
- const DEFAULT_MAX_RETRIES = 2
9
- const DEFAULT_BASE_DELAY_MS = 300
10
- const MAX_DELAY_MS = 10_000
11
-
12
- const sleep = async (ms: number): Promise<void> =>
13
- await new Promise((resolve) => setTimeout(resolve, ms))
14
-
15
- type HeaderValue = string | number | string[] | undefined
16
-
17
- const getHeaderValue = (
18
- headers: Record<string, unknown> | undefined,
19
- headerName: string
20
- ): HeaderValue => {
21
- if (!headers) return undefined
22
-
23
- const match = Object.entries(headers).find(
24
- ([key]) => key.toLowerCase() === headerName.toLowerCase()
25
- )
26
- return match ? (match[1] as HeaderValue) : undefined
27
- }
28
-
29
- const toNumber = (value: unknown): number | null => {
30
- const parsed = Number(value)
31
- return Number.isFinite(parsed) ? parsed : null
32
- }
33
-
34
- const getErrorResponse = (
35
- error: unknown
36
- ): { status?: number; headers?: Record<string, unknown> } | null => {
37
- if (!error || typeof error !== 'object') return null
38
-
39
- const maybeResponse = (error as { response?: unknown }).response
40
- if (!maybeResponse || typeof maybeResponse !== 'object') return null
41
-
42
- return maybeResponse as { status?: number; headers?: Record<string, unknown> }
43
- }
44
-
45
- export const isRateLimitError = (error: unknown): boolean => {
46
- const response = getErrorResponse(error)
47
- if (response?.status === 429) return true
48
-
49
- if (error && typeof error === 'object') {
50
- const code = toNumber((error as { code?: unknown }).code)
51
- if (code === 9) return true
52
-
53
- const status = toNumber((error as { status?: unknown }).status)
54
- if (status === 429) return true
55
-
56
- const message = (error as { message?: unknown }).message
57
- if (typeof message === 'string') {
58
- const normalizedMessage = message.toLowerCase()
59
- if (
60
- normalizedMessage.includes('too many requests') ||
61
- normalizedMessage.includes('rate limit')
62
- ) {
63
- return true
64
- }
65
- }
66
- }
67
-
68
- return false
69
- }
70
-
71
- export const getRetryDelayMsFromRateLimitError = (
72
- error: unknown,
73
- attempt: number,
74
- nowMs = Date.now()
75
- ): number => {
76
- const headers = getErrorResponse(error)?.headers
77
-
78
- const retryAfterHeader = getHeaderValue(headers, 'retry-after')
79
- const retryAfterSeconds = toNumber(
80
- Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader
81
- )
82
- if (retryAfterSeconds !== null && retryAfterSeconds >= 0) {
83
- return Math.min(Math.round(retryAfterSeconds * 1000), MAX_DELAY_MS)
84
- }
85
-
86
- const resetHeader = getHeaderValue(headers, 'x-ratelimit-reset')
87
- const resetEpochSeconds = toNumber(
88
- Array.isArray(resetHeader) ? resetHeader[0] : resetHeader
89
- )
90
- if (resetEpochSeconds !== null && resetEpochSeconds > 0) {
91
- const delayMs = Math.round(resetEpochSeconds * 1000 - nowMs)
92
- return Math.min(Math.max(delayMs, 0), MAX_DELAY_MS)
93
- }
94
-
95
- const exponentialBackoffMs = DEFAULT_BASE_DELAY_MS * 2 ** attempt
96
- return Math.min(exponentialBackoffMs, MAX_DELAY_MS)
97
- }
98
-
99
- interface QueryRequest {
100
- requestKey: string
101
- filters: QueryChannelsFilters
102
- sort?: QueryChannelsSort
103
- options?: QueryChannelsOptions
104
- }
105
-
106
- interface QueryChannelsManagerConfig {
107
- client: Pick<StreamChat, 'queryChannels'>
108
- maxRetries?: number
109
- wait?: (ms: number) => Promise<void>
110
- now?: () => number
111
- onRetry?: (details: {
112
- requestKey: string
113
- attempt: number
114
- delayMs: number
115
- error: unknown
116
- }) => void
117
- }
118
-
119
- export const createQueryChannelsManager = ({
120
- client,
121
- maxRetries = DEFAULT_MAX_RETRIES,
122
- wait = sleep,
123
- now = Date.now,
124
- onRetry,
125
- }: QueryChannelsManagerConfig) => {
126
- const inFlightByRequestKey = new Map<string, Promise<QueryChannelsResult>>()
127
-
128
- const query = async ({
129
- requestKey,
130
- filters,
131
- sort = {} as QueryChannelsSort,
132
- options = {} as QueryChannelsOptions,
133
- }: QueryRequest): Promise<QueryChannelsResult> => {
134
- const inFlightRequest = inFlightByRequestKey.get(requestKey)
135
- if (inFlightRequest) {
136
- return await inFlightRequest
137
- }
138
-
139
- const requestPromise = (async () => {
140
- for (let attempt = 0; ; attempt += 1) {
141
- try {
142
- return await client.queryChannels(filters, sort, options)
143
- } catch (error) {
144
- if (!isRateLimitError(error) || attempt >= maxRetries) {
145
- throw error
146
- }
147
-
148
- const delayMs = getRetryDelayMsFromRateLimitError(
149
- error,
150
- attempt,
151
- now()
152
- )
153
- onRetry?.({
154
- requestKey,
155
- attempt: attempt + 1,
156
- delayMs,
157
- error,
158
- })
159
- await wait(delayMs)
160
- }
161
- }
162
- })()
163
-
164
- inFlightByRequestKey.set(requestKey, requestPromise)
165
-
166
- try {
167
- return await requestPromise
168
- } finally {
169
- inFlightByRequestKey.delete(requestKey)
170
- }
171
- }
172
-
173
- return { query }
174
- }