@luxexchange/websocket 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 +15 -0
- package/.eslintrc.js +20 -0
- package/README.md +275 -0
- package/package.json +2 -3
- package/project.json +2 -6
- package/src/client/__tests__/MockWebSocket.ts +54 -0
- package/src/client/__tests__/createWebSocketClient.integration.test.ts +919 -0
- package/src/client/__tests__/testUtils.ts +113 -0
- package/src/client/createWebSocketClient.ts +281 -0
- package/src/index.ts +22 -0
- package/src/store/createZustandConnectionStore.test.ts +162 -0
- package/src/store/createZustandConnectionStore.ts +74 -0
- package/src/store/types.ts +17 -0
- package/src/subscriptions/SubscriptionManager.test.ts +740 -0
- package/src/subscriptions/SubscriptionManager.ts +283 -0
- package/src/subscriptions/types.ts +21 -0
- package/src/types.ts +88 -0
- package/src/utils/backoff.test.ts +48 -0
- package/src/utils/backoff.ts +15 -0
- package/tsconfig.json +20 -4
- package/tsconfig.lint.json +8 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { SubscriptionManager } from '@luxexchange/websocket/src/subscriptions/SubscriptionManager'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
interface TestParams {
|
|
5
|
+
channel: string
|
|
6
|
+
id: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TestMessage {
|
|
10
|
+
data: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function flushMicrotasks(): Promise<void> {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, 0))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createTestManager(
|
|
18
|
+
overrides?: Partial<{
|
|
19
|
+
subscribe: (connectionId: string, params: TestParams) => Promise<void>
|
|
20
|
+
unsubscribe: (connectionId: string, params: TestParams) => Promise<void>
|
|
21
|
+
subscribeBatch: (connectionId: string, params: TestParams[]) => Promise<void>
|
|
22
|
+
unsubscribeBatch: (connectionId: string, params: TestParams[]) => Promise<void>
|
|
23
|
+
refreshSession: (connectionId: string) => Promise<void>
|
|
24
|
+
onSubscriptionCountChange: (count: number) => void
|
|
25
|
+
}>,
|
|
26
|
+
): {
|
|
27
|
+
manager: SubscriptionManager<TestParams, TestMessage>
|
|
28
|
+
handler: {
|
|
29
|
+
subscribe: ReturnType<typeof vi.fn>
|
|
30
|
+
unsubscribe: ReturnType<typeof vi.fn>
|
|
31
|
+
subscribeBatch: ReturnType<typeof vi.fn>
|
|
32
|
+
unsubscribeBatch: ReturnType<typeof vi.fn>
|
|
33
|
+
refreshSession: ReturnType<typeof vi.fn>
|
|
34
|
+
}
|
|
35
|
+
} {
|
|
36
|
+
const handler = {
|
|
37
|
+
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
subscribeBatch: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
unsubscribeBatch: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
refreshSession: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
...overrides,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const manager = new SubscriptionManager<TestParams, TestMessage>({
|
|
46
|
+
handler,
|
|
47
|
+
createKey: (channel, params): string => `${channel}:${params.id}`,
|
|
48
|
+
onSubscriptionCountChange: overrides?.onSubscriptionCountChange,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return { manager, handler }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('SubscriptionManager', () => {
|
|
55
|
+
describe('subscribe', () => {
|
|
56
|
+
it('calls handler via subscribeBatch on first subscriber after microtask flush', async () => {
|
|
57
|
+
const { manager, handler } = createTestManager()
|
|
58
|
+
manager.setConnectionId('conn-123')
|
|
59
|
+
|
|
60
|
+
const callback = vi.fn()
|
|
61
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback })
|
|
62
|
+
|
|
63
|
+
// Not called yet — batched
|
|
64
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
65
|
+
expect(handler.subscribe).not.toHaveBeenCalled()
|
|
66
|
+
|
|
67
|
+
await flushMicrotasks()
|
|
68
|
+
|
|
69
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
70
|
+
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('falls back to individual subscribe when subscribeBatch is not provided', async () => {
|
|
74
|
+
const handler = {
|
|
75
|
+
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
76
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
refreshSession: vi.fn().mockResolvedValue(undefined),
|
|
78
|
+
}
|
|
79
|
+
const manager = new SubscriptionManager<TestParams, TestMessage>({
|
|
80
|
+
handler,
|
|
81
|
+
createKey: (channel, params): string => `${channel}:${params.id}`,
|
|
82
|
+
})
|
|
83
|
+
manager.setConnectionId('conn-123')
|
|
84
|
+
|
|
85
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
86
|
+
|
|
87
|
+
await flushMicrotasks()
|
|
88
|
+
|
|
89
|
+
expect(handler.subscribe).toHaveBeenCalledWith('conn-123', { channel: 'prices', id: 'token-1' })
|
|
90
|
+
expect(handler.subscribe).toHaveBeenCalledTimes(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('batches multiple subscribes in same microtask into one subscribeBatch call', async () => {
|
|
94
|
+
const { manager, handler } = createTestManager()
|
|
95
|
+
manager.setConnectionId('conn-123')
|
|
96
|
+
|
|
97
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
98
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
|
|
99
|
+
manager.subscribe({ channel: 'events', params: { channel: 'events', id: 'event-1' }, callback: vi.fn() })
|
|
100
|
+
|
|
101
|
+
await flushMicrotasks()
|
|
102
|
+
|
|
103
|
+
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [
|
|
105
|
+
{ channel: 'prices', id: 'token-1' },
|
|
106
|
+
{ channel: 'prices', id: 'token-2' },
|
|
107
|
+
{ channel: 'events', id: 'event-1' },
|
|
108
|
+
])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not call handler.subscribe for subsequent subscribers to same key', async () => {
|
|
112
|
+
const { manager, handler } = createTestManager()
|
|
113
|
+
manager.setConnectionId('conn-123')
|
|
114
|
+
|
|
115
|
+
const callback1 = vi.fn()
|
|
116
|
+
const callback2 = vi.fn()
|
|
117
|
+
|
|
118
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback1 })
|
|
119
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback2 })
|
|
120
|
+
|
|
121
|
+
await flushMicrotasks()
|
|
122
|
+
|
|
123
|
+
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
124
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not call handler when not connected', async () => {
|
|
128
|
+
const { manager, handler } = createTestManager()
|
|
129
|
+
// No connectionId set
|
|
130
|
+
|
|
131
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
132
|
+
|
|
133
|
+
await flushMicrotasks()
|
|
134
|
+
|
|
135
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
136
|
+
expect(handler.subscribe).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('returns synchronous unsubscribe function', () => {
|
|
140
|
+
const { manager } = createTestManager()
|
|
141
|
+
manager.setConnectionId('conn-123')
|
|
142
|
+
|
|
143
|
+
const unsubscribe = manager.subscribe({
|
|
144
|
+
channel: 'prices',
|
|
145
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
146
|
+
callback: vi.fn(),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(typeof unsubscribe).toBe('function')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('works without a callback (optional onMessage)', async () => {
|
|
153
|
+
const { manager, handler } = createTestManager()
|
|
154
|
+
manager.setConnectionId('conn-123')
|
|
155
|
+
|
|
156
|
+
const unsubscribe = manager.subscribe({
|
|
157
|
+
channel: 'prices',
|
|
158
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
await flushMicrotasks()
|
|
162
|
+
|
|
163
|
+
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
164
|
+
expect(typeof unsubscribe).toBe('function')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('unsubscribe', () => {
|
|
169
|
+
it('calls handler.unsubscribeBatch when last subscriber leaves', async () => {
|
|
170
|
+
const { manager, handler } = createTestManager()
|
|
171
|
+
manager.setConnectionId('conn-123')
|
|
172
|
+
|
|
173
|
+
const callback = vi.fn()
|
|
174
|
+
const unsubscribe = manager.subscribe({
|
|
175
|
+
channel: 'prices',
|
|
176
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
177
|
+
callback,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await flushMicrotasks()
|
|
181
|
+
|
|
182
|
+
unsubscribe()
|
|
183
|
+
|
|
184
|
+
await flushMicrotasks()
|
|
185
|
+
|
|
186
|
+
expect(handler.unsubscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('does not call handler.unsubscribe when other subscribers remain', async () => {
|
|
190
|
+
const { manager, handler } = createTestManager()
|
|
191
|
+
manager.setConnectionId('conn-123')
|
|
192
|
+
|
|
193
|
+
const callback1 = vi.fn()
|
|
194
|
+
const callback2 = vi.fn()
|
|
195
|
+
|
|
196
|
+
const unsubscribe1 = manager.subscribe({
|
|
197
|
+
channel: 'prices',
|
|
198
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
199
|
+
callback: callback1,
|
|
200
|
+
})
|
|
201
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback2 })
|
|
202
|
+
|
|
203
|
+
await flushMicrotasks()
|
|
204
|
+
|
|
205
|
+
unsubscribe1()
|
|
206
|
+
|
|
207
|
+
await flushMicrotasks()
|
|
208
|
+
|
|
209
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
210
|
+
expect(handler.unsubscribe).not.toHaveBeenCalled()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('batches multiple unsubscribes in same microtask', async () => {
|
|
214
|
+
const { manager, handler } = createTestManager()
|
|
215
|
+
manager.setConnectionId('conn-123')
|
|
216
|
+
|
|
217
|
+
const unsub1 = manager.subscribe({
|
|
218
|
+
channel: 'prices',
|
|
219
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
220
|
+
callback: vi.fn(),
|
|
221
|
+
})
|
|
222
|
+
const unsub2 = manager.subscribe({
|
|
223
|
+
channel: 'prices',
|
|
224
|
+
params: { channel: 'prices', id: 'token-2' },
|
|
225
|
+
callback: vi.fn(),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
await flushMicrotasks()
|
|
229
|
+
|
|
230
|
+
unsub1()
|
|
231
|
+
unsub2()
|
|
232
|
+
|
|
233
|
+
await flushMicrotasks()
|
|
234
|
+
|
|
235
|
+
expect(handler.unsubscribeBatch).toHaveBeenCalledTimes(1)
|
|
236
|
+
expect(handler.unsubscribeBatch).toHaveBeenCalledWith('conn-123', [
|
|
237
|
+
{ channel: 'prices', id: 'token-1' },
|
|
238
|
+
{ channel: 'prices', id: 'token-2' },
|
|
239
|
+
])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('does not unsubscribe when other callback-less subscribers remain', async () => {
|
|
243
|
+
const { manager, handler } = createTestManager()
|
|
244
|
+
manager.setConnectionId('conn-123')
|
|
245
|
+
|
|
246
|
+
const unsub1 = manager.subscribe({
|
|
247
|
+
channel: 'prices',
|
|
248
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
249
|
+
})
|
|
250
|
+
const unsub2 = manager.subscribe({
|
|
251
|
+
channel: 'prices',
|
|
252
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
await flushMicrotasks()
|
|
256
|
+
|
|
257
|
+
unsub1()
|
|
258
|
+
|
|
259
|
+
await flushMicrotasks()
|
|
260
|
+
|
|
261
|
+
// Second subscriber still active — should NOT unsubscribe
|
|
262
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
263
|
+
expect(handler.unsubscribe).not.toHaveBeenCalled()
|
|
264
|
+
|
|
265
|
+
// Subscription should still be tracked
|
|
266
|
+
expect(manager.hasActiveSubscriptions()).toBe(true)
|
|
267
|
+
expect(manager.getActiveSubscriptions()[0]?.subscriberCount).toBe(1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('unsubscribes when last callback-less subscriber leaves', async () => {
|
|
271
|
+
const { manager, handler } = createTestManager()
|
|
272
|
+
manager.setConnectionId('conn-123')
|
|
273
|
+
|
|
274
|
+
const unsub1 = manager.subscribe({
|
|
275
|
+
channel: 'prices',
|
|
276
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
277
|
+
})
|
|
278
|
+
const unsub2 = manager.subscribe({
|
|
279
|
+
channel: 'prices',
|
|
280
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
await flushMicrotasks()
|
|
284
|
+
|
|
285
|
+
unsub1()
|
|
286
|
+
unsub2()
|
|
287
|
+
|
|
288
|
+
await flushMicrotasks()
|
|
289
|
+
|
|
290
|
+
expect(handler.unsubscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
291
|
+
expect(manager.hasActiveSubscriptions()).toBe(false)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('tracks mixed callback and callback-less subscribers correctly', async () => {
|
|
295
|
+
const { manager, handler } = createTestManager()
|
|
296
|
+
manager.setConnectionId('conn-123')
|
|
297
|
+
|
|
298
|
+
const callback = vi.fn()
|
|
299
|
+
const unsubWithCallback = manager.subscribe({
|
|
300
|
+
channel: 'prices',
|
|
301
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
302
|
+
callback,
|
|
303
|
+
})
|
|
304
|
+
const unsubWithout = manager.subscribe({
|
|
305
|
+
channel: 'prices',
|
|
306
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
await flushMicrotasks()
|
|
310
|
+
|
|
311
|
+
// Removing callback-less subscriber should keep subscription alive
|
|
312
|
+
unsubWithout()
|
|
313
|
+
await flushMicrotasks()
|
|
314
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
315
|
+
expect(manager.getActiveSubscriptions()[0]?.subscriberCount).toBe(1)
|
|
316
|
+
|
|
317
|
+
// Dispatch should still reach the remaining callback
|
|
318
|
+
manager.dispatch('prices:token-1', { data: 'update' })
|
|
319
|
+
expect(callback).toHaveBeenCalledWith({ data: 'update' })
|
|
320
|
+
|
|
321
|
+
// Removing last subscriber should trigger unsubscribe
|
|
322
|
+
unsubWithCallback()
|
|
323
|
+
await flushMicrotasks()
|
|
324
|
+
expect(handler.unsubscribeBatch).toHaveBeenCalledTimes(1)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('double-invoking unsubscribe does not decrement subscriberCount twice', async () => {
|
|
328
|
+
const { manager, handler } = createTestManager()
|
|
329
|
+
manager.setConnectionId('conn-123')
|
|
330
|
+
|
|
331
|
+
const callbackA = vi.fn()
|
|
332
|
+
const callbackB = vi.fn()
|
|
333
|
+
|
|
334
|
+
const unsubA = manager.subscribe({
|
|
335
|
+
channel: 'prices',
|
|
336
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
337
|
+
callback: callbackA,
|
|
338
|
+
})
|
|
339
|
+
manager.subscribe({
|
|
340
|
+
channel: 'prices',
|
|
341
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
342
|
+
callback: callbackB,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
await flushMicrotasks()
|
|
346
|
+
|
|
347
|
+
// Call unsubA twice — second call should be a no-op
|
|
348
|
+
unsubA()
|
|
349
|
+
unsubA()
|
|
350
|
+
|
|
351
|
+
await flushMicrotasks()
|
|
352
|
+
|
|
353
|
+
// Subscription should still be active for B
|
|
354
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
355
|
+
expect(manager.hasActiveSubscriptions()).toBe(true)
|
|
356
|
+
expect(manager.getActiveSubscriptions()[0]?.subscriberCount).toBe(1)
|
|
357
|
+
|
|
358
|
+
// B should still receive messages
|
|
359
|
+
manager.dispatch('prices:token-1', { data: 'update' })
|
|
360
|
+
expect(callbackA).not.toHaveBeenCalled()
|
|
361
|
+
expect(callbackB).toHaveBeenCalledWith({ data: 'update' })
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('subscribe → unsubscribe → resubscribe in same microtask still sends subscribe (React Strict Mode)', async () => {
|
|
365
|
+
const { manager, handler } = createTestManager()
|
|
366
|
+
manager.setConnectionId('conn-123')
|
|
367
|
+
|
|
368
|
+
// Simulates React Strict Mode: effect runs, cleanup runs, effect re-runs
|
|
369
|
+
const unsub1 = manager.subscribe({
|
|
370
|
+
channel: 'prices',
|
|
371
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
372
|
+
})
|
|
373
|
+
unsub1() // Strict Mode cleanup
|
|
374
|
+
manager.subscribe({
|
|
375
|
+
channel: 'prices',
|
|
376
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
await flushMicrotasks()
|
|
380
|
+
|
|
381
|
+
// The subscribe MUST reach the server — entry is still active
|
|
382
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
383
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('unsubscribe → resubscribe in same microtask cancels both API calls (page navigation)', async () => {
|
|
387
|
+
const { manager, handler } = createTestManager()
|
|
388
|
+
manager.setConnectionId('conn-123')
|
|
389
|
+
|
|
390
|
+
// Initial subscribe + flush — server now knows about this subscription
|
|
391
|
+
const unsub = manager.subscribe({
|
|
392
|
+
channel: 'prices',
|
|
393
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
394
|
+
})
|
|
395
|
+
await flushMicrotasks()
|
|
396
|
+
handler.subscribeBatch.mockClear()
|
|
397
|
+
|
|
398
|
+
// Simulates navigation: old page unmounts, new page mounts same token
|
|
399
|
+
unsub()
|
|
400
|
+
manager.subscribe({
|
|
401
|
+
channel: 'prices',
|
|
402
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
await flushMicrotasks()
|
|
406
|
+
|
|
407
|
+
// No API calls — server subscription is still valid
|
|
408
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
409
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('subscribe + immediate unsubscribe in same microtask produces net-zero API calls', async () => {
|
|
413
|
+
const { manager, handler } = createTestManager()
|
|
414
|
+
manager.setConnectionId('conn-123')
|
|
415
|
+
|
|
416
|
+
const unsub = manager.subscribe({
|
|
417
|
+
channel: 'prices',
|
|
418
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
419
|
+
callback: vi.fn(),
|
|
420
|
+
})
|
|
421
|
+
unsub()
|
|
422
|
+
|
|
423
|
+
await flushMicrotasks()
|
|
424
|
+
|
|
425
|
+
// Both pending subscribe and unsubscribe should cancel out
|
|
426
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
427
|
+
expect(handler.subscribe).not.toHaveBeenCalled()
|
|
428
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
429
|
+
expect(handler.unsubscribe).not.toHaveBeenCalled()
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
describe('dispatch', () => {
|
|
434
|
+
it('routes messages to correct callbacks', () => {
|
|
435
|
+
const { manager } = createTestManager()
|
|
436
|
+
manager.setConnectionId('conn-123')
|
|
437
|
+
|
|
438
|
+
const callback1 = vi.fn()
|
|
439
|
+
const callback2 = vi.fn()
|
|
440
|
+
|
|
441
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback1 })
|
|
442
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: callback2 })
|
|
443
|
+
|
|
444
|
+
manager.dispatch('prices:token-1', { data: 'price-update-1' })
|
|
445
|
+
|
|
446
|
+
expect(callback1).toHaveBeenCalledWith({ data: 'price-update-1' })
|
|
447
|
+
expect(callback2).not.toHaveBeenCalled()
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('calls all callbacks for same subscription', () => {
|
|
451
|
+
const { manager } = createTestManager()
|
|
452
|
+
manager.setConnectionId('conn-123')
|
|
453
|
+
|
|
454
|
+
const callback1 = vi.fn()
|
|
455
|
+
const callback2 = vi.fn()
|
|
456
|
+
|
|
457
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback1 })
|
|
458
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback2 })
|
|
459
|
+
|
|
460
|
+
manager.dispatch('prices:token-1', { data: 'price-update' })
|
|
461
|
+
|
|
462
|
+
expect(callback1).toHaveBeenCalledWith({ data: 'price-update' })
|
|
463
|
+
expect(callback2).toHaveBeenCalledWith({ data: 'price-update' })
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('ignores messages for unknown subscriptions', () => {
|
|
467
|
+
const { manager } = createTestManager()
|
|
468
|
+
|
|
469
|
+
// Should not throw
|
|
470
|
+
manager.dispatch('unknown:key', { data: 'unknown' })
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
describe('resubscribeAll', () => {
|
|
475
|
+
it('resubscribes all active subscriptions with new connectionId using subscribeBatch', async () => {
|
|
476
|
+
const { manager, handler } = createTestManager()
|
|
477
|
+
manager.setConnectionId('conn-old')
|
|
478
|
+
|
|
479
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
480
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
|
|
481
|
+
|
|
482
|
+
await flushMicrotasks()
|
|
483
|
+
handler.subscribeBatch.mockClear()
|
|
484
|
+
|
|
485
|
+
await manager.resubscribeAll('conn-new')
|
|
486
|
+
|
|
487
|
+
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
488
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-new', [
|
|
489
|
+
{ channel: 'prices', id: 'token-1' },
|
|
490
|
+
{ channel: 'prices', id: 'token-2' },
|
|
491
|
+
])
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('falls back to individual subscribe calls when subscribeBatch is not provided', async () => {
|
|
495
|
+
const handler = {
|
|
496
|
+
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
497
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
498
|
+
refreshSession: vi.fn().mockResolvedValue(undefined),
|
|
499
|
+
}
|
|
500
|
+
const manager = new SubscriptionManager<TestParams, TestMessage>({
|
|
501
|
+
handler,
|
|
502
|
+
createKey: (channel, params): string => `${channel}:${params.id}`,
|
|
503
|
+
})
|
|
504
|
+
manager.setConnectionId('conn-old')
|
|
505
|
+
|
|
506
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
507
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
|
|
508
|
+
|
|
509
|
+
await flushMicrotasks()
|
|
510
|
+
handler.subscribe.mockClear()
|
|
511
|
+
|
|
512
|
+
await manager.resubscribeAll('conn-new')
|
|
513
|
+
|
|
514
|
+
expect(handler.subscribe).toHaveBeenCalledTimes(2)
|
|
515
|
+
expect(handler.subscribe).toHaveBeenCalledWith('conn-new', { channel: 'prices', id: 'token-1' })
|
|
516
|
+
expect(handler.subscribe).toHaveBeenCalledWith('conn-new', { channel: 'prices', id: 'token-2' })
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('updates internal connectionId', async () => {
|
|
520
|
+
const { manager } = createTestManager()
|
|
521
|
+
manager.setConnectionId('conn-old')
|
|
522
|
+
|
|
523
|
+
await manager.resubscribeAll('conn-new')
|
|
524
|
+
|
|
525
|
+
expect(manager.getConnectionId()).toBe('conn-new')
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
describe('onSubscriptionCountChange', () => {
|
|
530
|
+
it('fires when first subscription is added', () => {
|
|
531
|
+
const onSubscriptionCountChange = vi.fn()
|
|
532
|
+
const { manager } = createTestManager({ onSubscriptionCountChange })
|
|
533
|
+
manager.setConnectionId('conn-123')
|
|
534
|
+
|
|
535
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
536
|
+
|
|
537
|
+
expect(onSubscriptionCountChange).toHaveBeenCalledWith(1)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('fires when subscription count increases', () => {
|
|
541
|
+
const onSubscriptionCountChange = vi.fn()
|
|
542
|
+
const { manager } = createTestManager({ onSubscriptionCountChange })
|
|
543
|
+
manager.setConnectionId('conn-123')
|
|
544
|
+
|
|
545
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
546
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
|
|
547
|
+
|
|
548
|
+
expect(onSubscriptionCountChange).toHaveBeenCalledTimes(2)
|
|
549
|
+
expect(onSubscriptionCountChange).toHaveBeenNthCalledWith(1, 1)
|
|
550
|
+
expect(onSubscriptionCountChange).toHaveBeenNthCalledWith(2, 2)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('fires when last subscription is removed', async () => {
|
|
554
|
+
const onSubscriptionCountChange = vi.fn()
|
|
555
|
+
const { manager } = createTestManager({ onSubscriptionCountChange })
|
|
556
|
+
manager.setConnectionId('conn-123')
|
|
557
|
+
|
|
558
|
+
const unsub = manager.subscribe({
|
|
559
|
+
channel: 'prices',
|
|
560
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
561
|
+
callback: vi.fn(),
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
onSubscriptionCountChange.mockClear()
|
|
565
|
+
unsub()
|
|
566
|
+
|
|
567
|
+
expect(onSubscriptionCountChange).toHaveBeenCalledWith(0)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('does not fire for additional subscribers to same key', () => {
|
|
571
|
+
const onSubscriptionCountChange = vi.fn()
|
|
572
|
+
const { manager } = createTestManager({ onSubscriptionCountChange })
|
|
573
|
+
manager.setConnectionId('conn-123')
|
|
574
|
+
|
|
575
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
576
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
577
|
+
|
|
578
|
+
// Only fired once — second subscriber to same key doesn't change unique subscription count
|
|
579
|
+
expect(onSubscriptionCountChange).toHaveBeenCalledTimes(1)
|
|
580
|
+
expect(onSubscriptionCountChange).toHaveBeenCalledWith(1)
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
describe('getActiveSubscriptions', () => {
|
|
585
|
+
it('returns all active subscriptions with subscriber counts', () => {
|
|
586
|
+
const { manager } = createTestManager()
|
|
587
|
+
manager.setConnectionId('conn-123')
|
|
588
|
+
|
|
589
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
590
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
591
|
+
manager.subscribe({ channel: 'events', params: { channel: 'events', id: 'event-1' }, callback: vi.fn() })
|
|
592
|
+
|
|
593
|
+
const subscriptions = manager.getActiveSubscriptions()
|
|
594
|
+
|
|
595
|
+
expect(subscriptions).toHaveLength(2)
|
|
596
|
+
expect(subscriptions).toContainEqual({
|
|
597
|
+
channel: 'prices',
|
|
598
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
599
|
+
subscriberCount: 2,
|
|
600
|
+
})
|
|
601
|
+
expect(subscriptions).toContainEqual({
|
|
602
|
+
channel: 'events',
|
|
603
|
+
params: { channel: 'events', id: 'event-1' },
|
|
604
|
+
subscriberCount: 1,
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('counts callback-less subscribers in subscriberCount', () => {
|
|
609
|
+
const { manager } = createTestManager()
|
|
610
|
+
manager.setConnectionId('conn-123')
|
|
611
|
+
|
|
612
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' } })
|
|
613
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' } })
|
|
614
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
615
|
+
|
|
616
|
+
const subscriptions = manager.getActiveSubscriptions()
|
|
617
|
+
|
|
618
|
+
expect(subscriptions).toHaveLength(1)
|
|
619
|
+
expect(subscriptions[0]?.subscriberCount).toBe(3)
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('hasActiveSubscriptions', () => {
|
|
624
|
+
it('returns false when no subscriptions', () => {
|
|
625
|
+
const { manager } = createTestManager()
|
|
626
|
+
|
|
627
|
+
expect(manager.hasActiveSubscriptions()).toBe(false)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('returns true when subscriptions exist', () => {
|
|
631
|
+
const { manager } = createTestManager()
|
|
632
|
+
manager.setConnectionId('conn-123')
|
|
633
|
+
|
|
634
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
635
|
+
|
|
636
|
+
expect(manager.hasActiveSubscriptions()).toBe(true)
|
|
637
|
+
})
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
describe('clear', () => {
|
|
641
|
+
it('removes all subscriptions, pending batches, and resets connectionId', () => {
|
|
642
|
+
const { manager } = createTestManager()
|
|
643
|
+
manager.setConnectionId('conn-123')
|
|
644
|
+
|
|
645
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
646
|
+
|
|
647
|
+
manager.clear()
|
|
648
|
+
|
|
649
|
+
expect(manager.hasActiveSubscriptions()).toBe(false)
|
|
650
|
+
expect(manager.getConnectionId()).toBe(null)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('prevents pending subscribes from flushing after clear', async () => {
|
|
654
|
+
const { manager, handler } = createTestManager()
|
|
655
|
+
manager.setConnectionId('conn-123')
|
|
656
|
+
|
|
657
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
658
|
+
|
|
659
|
+
// Clear before microtask flush
|
|
660
|
+
manager.clear()
|
|
661
|
+
|
|
662
|
+
await flushMicrotasks()
|
|
663
|
+
|
|
664
|
+
// The subscribe batch should not have fired since connectionId was cleared
|
|
665
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
666
|
+
expect(handler.subscribe).not.toHaveBeenCalled()
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
describe('refreshSession', () => {
|
|
671
|
+
it('calls handler.refreshSession when connected', async () => {
|
|
672
|
+
const { manager, handler } = createTestManager()
|
|
673
|
+
manager.setConnectionId('conn-123')
|
|
674
|
+
|
|
675
|
+
await manager.refreshSession()
|
|
676
|
+
|
|
677
|
+
expect(handler.refreshSession).toHaveBeenCalledWith('conn-123')
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('does nothing when not connected', async () => {
|
|
681
|
+
const { manager, handler } = createTestManager()
|
|
682
|
+
|
|
683
|
+
await manager.refreshSession()
|
|
684
|
+
|
|
685
|
+
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
describe('error handling', () => {
|
|
690
|
+
it('calls onError when subscribeBatch fails (errors are async, not thrown)', async () => {
|
|
691
|
+
const onError = vi.fn()
|
|
692
|
+
const error = new Error('Subscribe failed')
|
|
693
|
+
|
|
694
|
+
const manager = new SubscriptionManager<TestParams, TestMessage>({
|
|
695
|
+
handler: {
|
|
696
|
+
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
697
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
698
|
+
subscribeBatch: vi.fn().mockRejectedValue(error),
|
|
699
|
+
unsubscribeBatch: vi.fn().mockResolvedValue(undefined),
|
|
700
|
+
},
|
|
701
|
+
createKey: (channel, params): string => `${channel}:${params.id}`,
|
|
702
|
+
onError,
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
manager.setConnectionId('conn-123')
|
|
706
|
+
manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
|
|
707
|
+
|
|
708
|
+
await flushMicrotasks()
|
|
709
|
+
|
|
710
|
+
expect(onError).toHaveBeenCalledWith(error, 'subscribe')
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it('calls onError when callback throws during dispatch', () => {
|
|
714
|
+
const onError = vi.fn()
|
|
715
|
+
const error = new Error('Callback error')
|
|
716
|
+
|
|
717
|
+
const manager = new SubscriptionManager<TestParams, TestMessage>({
|
|
718
|
+
handler: {
|
|
719
|
+
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
720
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
721
|
+
},
|
|
722
|
+
createKey: (channel, params): string => `${channel}:${params.id}`,
|
|
723
|
+
onError,
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
manager.setConnectionId('conn-123')
|
|
727
|
+
manager.subscribe({
|
|
728
|
+
channel: 'prices',
|
|
729
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
730
|
+
callback: (): void => {
|
|
731
|
+
throw error
|
|
732
|
+
},
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
manager.dispatch('prices:token-1', { data: 'test' })
|
|
736
|
+
|
|
737
|
+
expect(onError).toHaveBeenCalledWith(error, 'dispatch')
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
})
|