@luxexchange/websocket 1.0.0 → 1.0.1
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/package.json +2 -2
- package/project.json +1 -1
- package/.depcheckrc +0 -15
- package/.eslintrc.js +0 -20
- package/README.md +0 -275
- package/src/client/__tests__/MockWebSocket.ts +0 -54
- package/src/client/__tests__/createWebSocketClient.integration.test.ts +0 -831
- package/src/client/__tests__/testUtils.ts +0 -113
- package/src/client/createWebSocketClient.ts +0 -262
- package/src/index.ts +0 -22
- package/src/store/createZustandConnectionStore.test.ts +0 -162
- package/src/store/createZustandConnectionStore.ts +0 -74
- package/src/store/types.ts +0 -17
- package/src/subscriptions/SubscriptionManager.test.ts +0 -556
- package/src/subscriptions/SubscriptionManager.ts +0 -274
- package/src/subscriptions/types.ts +0 -20
- package/src/types.ts +0 -88
- package/src/utils/backoff.test.ts +0 -48
- package/src/utils/backoff.ts +0 -15
- package/tsconfig.lint.json +0 -8
- package/vitest.config.ts +0 -17
|
@@ -1,831 +0,0 @@
|
|
|
1
|
-
import type { TestMessage } from '@luxexchange/websocket/src/client/__tests__/testUtils'
|
|
2
|
-
import {
|
|
3
|
-
connectViaSubscribe,
|
|
4
|
-
createTestClient,
|
|
5
|
-
flushMicrotasks,
|
|
6
|
-
flushPromises,
|
|
7
|
-
} from '@luxexchange/websocket/src/client/__tests__/testUtils'
|
|
8
|
-
import type { ConnectionStatus } from '@luxexchange/websocket/src/types'
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
-
|
|
11
|
-
describe('createWebSocketClient integration', () => {
|
|
12
|
-
describe('lazy connection lifecycle', () => {
|
|
13
|
-
it('first subscribe triggers connection: status transitions to connecting then connected', () => {
|
|
14
|
-
const { client, mockSocket } = createTestClient()
|
|
15
|
-
const statusChanges: ConnectionStatus[] = []
|
|
16
|
-
|
|
17
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
18
|
-
|
|
19
|
-
// Subscribe triggers lazy connect
|
|
20
|
-
client.subscribe({
|
|
21
|
-
channel: 'prices',
|
|
22
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
23
|
-
onMessage: vi.fn(),
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
expect(statusChanges).toEqual(['connecting'])
|
|
27
|
-
expect(client.getConnectionStatus()).toBe('connecting')
|
|
28
|
-
|
|
29
|
-
mockSocket.simulateOpen()
|
|
30
|
-
|
|
31
|
-
expect(statusChanges).toEqual(['connecting', 'connected'])
|
|
32
|
-
expect(client.getConnectionStatus()).toBe('connected')
|
|
33
|
-
expect(client.isConnected()).toBe(true)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('last unsubscribe triggers disconnect: clears all state and sets status to disconnected', async () => {
|
|
37
|
-
const { client, mockSocket } = createTestClient()
|
|
38
|
-
const statusChanges: ConnectionStatus[] = []
|
|
39
|
-
|
|
40
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
41
|
-
|
|
42
|
-
const unsub = client.subscribe({
|
|
43
|
-
channel: 'prices',
|
|
44
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
45
|
-
onMessage: vi.fn(),
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
mockSocket.simulateOpen()
|
|
49
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
50
|
-
await flushMicrotasks()
|
|
51
|
-
|
|
52
|
-
unsub()
|
|
53
|
-
|
|
54
|
-
// After last unsubscribe, should be disconnected
|
|
55
|
-
expect(client.getConnectionStatus()).toBe('disconnected')
|
|
56
|
-
expect(client.isConnected()).toBe(false)
|
|
57
|
-
expect(client.getConnectionId()).toBe(null)
|
|
58
|
-
expect(statusChanges).toContain('disconnected')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('second subscribe to same params does not reconnect', () => {
|
|
62
|
-
const { client, mockSocket } = createTestClient()
|
|
63
|
-
const statusChanges: ConnectionStatus[] = []
|
|
64
|
-
|
|
65
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
66
|
-
|
|
67
|
-
client.subscribe({
|
|
68
|
-
channel: 'prices',
|
|
69
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
70
|
-
onMessage: vi.fn(),
|
|
71
|
-
})
|
|
72
|
-
mockSocket.simulateOpen()
|
|
73
|
-
|
|
74
|
-
// Second subscribe with same key — should not create new connection
|
|
75
|
-
client.subscribe({
|
|
76
|
-
channel: 'prices',
|
|
77
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
78
|
-
onMessage: vi.fn(),
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
expect(statusChanges).toEqual(['connecting', 'connected'])
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('connection established message stores connectionId and fires callback', () => {
|
|
85
|
-
const { client, mockSocket } = createTestClient()
|
|
86
|
-
const connectionIds: string[] = []
|
|
87
|
-
|
|
88
|
-
client.onConnectionEstablished((id) => connectionIds.push(id))
|
|
89
|
-
|
|
90
|
-
client.subscribe({
|
|
91
|
-
channel: 'prices',
|
|
92
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
93
|
-
onMessage: vi.fn(),
|
|
94
|
-
})
|
|
95
|
-
mockSocket.simulateOpen()
|
|
96
|
-
|
|
97
|
-
expect(client.getConnectionId()).toBe(null)
|
|
98
|
-
|
|
99
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
100
|
-
|
|
101
|
-
expect(client.getConnectionId()).toBe('conn-123')
|
|
102
|
-
expect(connectionIds).toEqual(['conn-123'])
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('disconnect does not produce spurious reconnecting status', async () => {
|
|
106
|
-
const { client, mockSocket } = createTestClient()
|
|
107
|
-
const statusChanges: ConnectionStatus[] = []
|
|
108
|
-
|
|
109
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
110
|
-
|
|
111
|
-
const unsub = client.subscribe({
|
|
112
|
-
channel: 'prices',
|
|
113
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
114
|
-
onMessage: vi.fn(),
|
|
115
|
-
})
|
|
116
|
-
mockSocket.simulateOpen()
|
|
117
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
118
|
-
await flushMicrotasks()
|
|
119
|
-
|
|
120
|
-
// Unsubscribing the last subscriber triggers disconnect
|
|
121
|
-
// wasConnected is set to false BEFORE socket.close(), preventing spurious 'reconnecting'
|
|
122
|
-
unsub()
|
|
123
|
-
|
|
124
|
-
// Should go straight to disconnected without reconnecting
|
|
125
|
-
expect(statusChanges).toEqual(['connecting', 'connected', 'disconnected'])
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('subscription flow', () => {
|
|
130
|
-
it('subscribe() calls handler via subscribeBatch with connectionId after microtask flush', async () => {
|
|
131
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
132
|
-
|
|
133
|
-
connectViaSubscribe({ client, mockSocket })
|
|
134
|
-
|
|
135
|
-
client.subscribe({
|
|
136
|
-
channel: 'prices',
|
|
137
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
138
|
-
onMessage: vi.fn(),
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
await flushMicrotasks()
|
|
142
|
-
|
|
143
|
-
// The connectViaSubscribe helper creates one subscription, so subscribeBatch is called
|
|
144
|
-
// for resubscribeAll after connectionId, then our new subscribe
|
|
145
|
-
expect(handler.subscribeBatch).toHaveBeenCalledWith(
|
|
146
|
-
'conn-123',
|
|
147
|
-
expect.arrayContaining([{ channel: 'prices', id: 'token-1' }]),
|
|
148
|
-
)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('subscribe() before connection is established queues subs until connected', async () => {
|
|
152
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
153
|
-
|
|
154
|
-
// Subscribe before socket is open — triggers connection
|
|
155
|
-
client.subscribe({
|
|
156
|
-
channel: 'prices',
|
|
157
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
158
|
-
onMessage: vi.fn(),
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
await flushMicrotasks()
|
|
162
|
-
|
|
163
|
-
// No connectionId yet, so no REST calls
|
|
164
|
-
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
165
|
-
|
|
166
|
-
// Now connection establishes
|
|
167
|
-
mockSocket.simulateOpen()
|
|
168
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
169
|
-
|
|
170
|
-
await flushMicrotasks()
|
|
171
|
-
|
|
172
|
-
// resubscribeAll sends the queued subscription
|
|
173
|
-
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
it('multiple subscribers to same params calls handler once (reference counting)', async () => {
|
|
177
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
178
|
-
|
|
179
|
-
connectViaSubscribe({ client, mockSocket })
|
|
180
|
-
await flushMicrotasks()
|
|
181
|
-
handler.subscribeBatch.mockClear()
|
|
182
|
-
|
|
183
|
-
client.subscribe({
|
|
184
|
-
channel: 'prices',
|
|
185
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
186
|
-
onMessage: vi.fn(),
|
|
187
|
-
})
|
|
188
|
-
client.subscribe({
|
|
189
|
-
channel: 'prices',
|
|
190
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
191
|
-
onMessage: vi.fn(),
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
await flushMicrotasks()
|
|
195
|
-
|
|
196
|
-
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
it('unsubscribe last subscriber calls handler.unsubscribeBatch', async () => {
|
|
200
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
201
|
-
|
|
202
|
-
connectViaSubscribe({ client, mockSocket })
|
|
203
|
-
await flushMicrotasks()
|
|
204
|
-
handler.unsubscribeBatch.mockClear()
|
|
205
|
-
|
|
206
|
-
const unsub = client.subscribe({
|
|
207
|
-
channel: 'prices',
|
|
208
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
209
|
-
onMessage: vi.fn(),
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
await flushMicrotasks()
|
|
213
|
-
unsub()
|
|
214
|
-
await flushMicrotasks()
|
|
215
|
-
|
|
216
|
-
expect(handler.unsubscribeBatch).toHaveBeenCalledWith(
|
|
217
|
-
'conn-123',
|
|
218
|
-
expect.arrayContaining([{ channel: 'prices', id: 'token-1' }]),
|
|
219
|
-
)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('unsubscribe with remaining subscribers does NOT call handler.unsubscribe', async () => {
|
|
223
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
224
|
-
|
|
225
|
-
connectViaSubscribe({ client, mockSocket })
|
|
226
|
-
await flushMicrotasks()
|
|
227
|
-
handler.unsubscribeBatch.mockClear()
|
|
228
|
-
handler.unsubscribe.mockClear()
|
|
229
|
-
|
|
230
|
-
const unsub1 = client.subscribe({
|
|
231
|
-
channel: 'prices',
|
|
232
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
233
|
-
onMessage: vi.fn(),
|
|
234
|
-
})
|
|
235
|
-
client.subscribe({
|
|
236
|
-
channel: 'prices',
|
|
237
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
238
|
-
onMessage: vi.fn(),
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
await flushMicrotasks()
|
|
242
|
-
unsub1()
|
|
243
|
-
await flushMicrotasks()
|
|
244
|
-
|
|
245
|
-
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
246
|
-
expect(handler.unsubscribe).not.toHaveBeenCalled()
|
|
247
|
-
})
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
describe('message routing', () => {
|
|
251
|
-
it('message received is parsed and routed to correct callback', async () => {
|
|
252
|
-
const { client, mockSocket } = createTestClient()
|
|
253
|
-
const messages: TestMessage[] = []
|
|
254
|
-
|
|
255
|
-
connectViaSubscribe({ client, mockSocket })
|
|
256
|
-
|
|
257
|
-
client.subscribe({
|
|
258
|
-
channel: 'prices',
|
|
259
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
260
|
-
onMessage: (m) => messages.push(m),
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
await flushMicrotasks()
|
|
264
|
-
|
|
265
|
-
mockSocket.simulateMessage({
|
|
266
|
-
channel: 'prices',
|
|
267
|
-
key: 'prices:token-1',
|
|
268
|
-
data: { data: 'price-update', price: 3000 },
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
expect(messages).toEqual([{ data: 'price-update', price: 3000 }])
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
it('message for unknown subscription is ignored', async () => {
|
|
275
|
-
const { client, mockSocket } = createTestClient()
|
|
276
|
-
const messages: TestMessage[] = []
|
|
277
|
-
|
|
278
|
-
connectViaSubscribe({ client, mockSocket })
|
|
279
|
-
|
|
280
|
-
client.subscribe({
|
|
281
|
-
channel: 'prices',
|
|
282
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
283
|
-
onMessage: (m) => messages.push(m),
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
await flushMicrotasks()
|
|
287
|
-
|
|
288
|
-
// Message for different subscription key
|
|
289
|
-
mockSocket.simulateMessage({
|
|
290
|
-
channel: 'prices',
|
|
291
|
-
key: 'prices:token-2',
|
|
292
|
-
data: { data: 'unknown' },
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
expect(messages).toEqual([])
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
it('message to multiple subscribers invokes all callbacks', async () => {
|
|
299
|
-
const { client, mockSocket } = createTestClient()
|
|
300
|
-
const messages1: TestMessage[] = []
|
|
301
|
-
const messages2: TestMessage[] = []
|
|
302
|
-
|
|
303
|
-
connectViaSubscribe({ client, mockSocket })
|
|
304
|
-
|
|
305
|
-
client.subscribe({
|
|
306
|
-
channel: 'prices',
|
|
307
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
308
|
-
onMessage: (m) => messages1.push(m),
|
|
309
|
-
})
|
|
310
|
-
client.subscribe({
|
|
311
|
-
channel: 'prices',
|
|
312
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
313
|
-
onMessage: (m) => messages2.push(m),
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
await flushMicrotasks()
|
|
317
|
-
|
|
318
|
-
mockSocket.simulateMessage({
|
|
319
|
-
channel: 'prices',
|
|
320
|
-
key: 'prices:token-1',
|
|
321
|
-
data: { data: 'price-update' },
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
expect(messages1).toEqual([{ data: 'price-update' }])
|
|
325
|
-
expect(messages2).toEqual([{ data: 'price-update' }])
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
it('malformed message calls onError and does not crash', () => {
|
|
329
|
-
const onError = vi.fn()
|
|
330
|
-
const { client, mockSocket } = createTestClient({ onError })
|
|
331
|
-
|
|
332
|
-
connectViaSubscribe({ client, mockSocket })
|
|
333
|
-
|
|
334
|
-
// Simulate a message that will fail JSON.parse
|
|
335
|
-
const handlers = (mockSocket as unknown as { listeners: Map<string, Set<(e: unknown) => void>> }).listeners.get(
|
|
336
|
-
'message',
|
|
337
|
-
)
|
|
338
|
-
if (handlers) {
|
|
339
|
-
for (const handler of handlers) {
|
|
340
|
-
handler({ data: 'not valid json' })
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
expect(onError).toHaveBeenCalled()
|
|
345
|
-
})
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
describe('reconnection', () => {
|
|
349
|
-
it('socket closes after connected sets status to reconnecting', () => {
|
|
350
|
-
const { client, mockSocket } = createTestClient()
|
|
351
|
-
const statusChanges: ConnectionStatus[] = []
|
|
352
|
-
|
|
353
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
354
|
-
|
|
355
|
-
// Subscribe triggers lazy connect
|
|
356
|
-
client.subscribe({
|
|
357
|
-
channel: 'prices',
|
|
358
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
359
|
-
onMessage: vi.fn(),
|
|
360
|
-
})
|
|
361
|
-
mockSocket.simulateOpen()
|
|
362
|
-
|
|
363
|
-
mockSocket.simulateClose()
|
|
364
|
-
|
|
365
|
-
expect(statusChanges).toContain('reconnecting')
|
|
366
|
-
expect(client.getConnectionStatus()).toBe('reconnecting')
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
it('socket closes before fully connected sets status to disconnected', () => {
|
|
370
|
-
const { client, mockSocket } = createTestClient()
|
|
371
|
-
const statusChanges: ConnectionStatus[] = []
|
|
372
|
-
|
|
373
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
374
|
-
|
|
375
|
-
// Subscribe triggers lazy connect
|
|
376
|
-
client.subscribe({
|
|
377
|
-
channel: 'prices',
|
|
378
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
379
|
-
onMessage: vi.fn(),
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
// Don't simulate open - close before connecting
|
|
383
|
-
mockSocket.simulateClose()
|
|
384
|
-
|
|
385
|
-
expect(statusChanges).toContain('disconnected')
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
it('resubscribes all after reconnection with new connectionId', async () => {
|
|
389
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
390
|
-
|
|
391
|
-
connectViaSubscribe({ client, mockSocket, connectionId: 'conn-1' })
|
|
392
|
-
|
|
393
|
-
client.subscribe({
|
|
394
|
-
channel: 'prices',
|
|
395
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
396
|
-
onMessage: vi.fn(),
|
|
397
|
-
})
|
|
398
|
-
client.subscribe({
|
|
399
|
-
channel: 'events',
|
|
400
|
-
params: { channel: 'events', id: 'event-1' },
|
|
401
|
-
onMessage: vi.fn(),
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
await flushMicrotasks()
|
|
405
|
-
handler.subscribeBatch.mockClear()
|
|
406
|
-
|
|
407
|
-
// Simulate reconnect
|
|
408
|
-
mockSocket.simulateClose()
|
|
409
|
-
mockSocket.simulateOpen()
|
|
410
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-2' })
|
|
411
|
-
|
|
412
|
-
await flushPromises()
|
|
413
|
-
|
|
414
|
-
// Resubscription happens with the new connectionId via subscribeBatch
|
|
415
|
-
expect(handler.subscribeBatch).toHaveBeenCalledWith(
|
|
416
|
-
'conn-2',
|
|
417
|
-
expect.arrayContaining([
|
|
418
|
-
{ channel: 'prices', id: 'token-1' },
|
|
419
|
-
{ channel: 'events', id: 'event-1' },
|
|
420
|
-
]),
|
|
421
|
-
)
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
it('connection established message updates connectionId', () => {
|
|
425
|
-
const { client, mockSocket } = createTestClient()
|
|
426
|
-
|
|
427
|
-
connectViaSubscribe({ client, mockSocket, connectionId: 'conn-1' })
|
|
428
|
-
|
|
429
|
-
expect(client.getConnectionId()).toBe('conn-1')
|
|
430
|
-
|
|
431
|
-
// Simulate reconnect with new connectionId
|
|
432
|
-
mockSocket.simulateClose()
|
|
433
|
-
mockSocket.simulateOpen()
|
|
434
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-2' })
|
|
435
|
-
|
|
436
|
-
expect(client.getConnectionId()).toBe('conn-2')
|
|
437
|
-
})
|
|
438
|
-
})
|
|
439
|
-
|
|
440
|
-
describe('early unsubscribe', () => {
|
|
441
|
-
it('subscribe + immediate unsubscribe in same microtask produces net-zero API calls', async () => {
|
|
442
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
443
|
-
|
|
444
|
-
connectViaSubscribe({ client, mockSocket })
|
|
445
|
-
await flushMicrotasks()
|
|
446
|
-
handler.subscribeBatch.mockClear()
|
|
447
|
-
handler.subscribe.mockClear()
|
|
448
|
-
handler.unsubscribeBatch.mockClear()
|
|
449
|
-
handler.unsubscribe.mockClear()
|
|
450
|
-
|
|
451
|
-
// Subscribe and immediately unsubscribe in same microtask
|
|
452
|
-
const unsub = client.subscribe({
|
|
453
|
-
channel: 'prices',
|
|
454
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
455
|
-
onMessage: vi.fn(),
|
|
456
|
-
})
|
|
457
|
-
unsub()
|
|
458
|
-
|
|
459
|
-
await flushMicrotasks()
|
|
460
|
-
|
|
461
|
-
// Net-zero: the pending subscribe and unsubscribe for the same key cancel out
|
|
462
|
-
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
463
|
-
expect(handler.subscribe).not.toHaveBeenCalled()
|
|
464
|
-
// Note: unsubscribeBatch may be called for the __connect helper key if it was the last subscriber
|
|
465
|
-
// But for our 'prices:token-1' key, no unsubscribe should occur since it was never subscribed
|
|
466
|
-
})
|
|
467
|
-
})
|
|
468
|
-
|
|
469
|
-
describe('error handling', () => {
|
|
470
|
-
it('handler.subscribe error calls onError', async () => {
|
|
471
|
-
const onError = vi.fn()
|
|
472
|
-
const subscribeError = new Error('Subscribe failed')
|
|
473
|
-
|
|
474
|
-
const { client, mockSocket } = createTestClient({
|
|
475
|
-
onError,
|
|
476
|
-
subscriptionHandler: {
|
|
477
|
-
subscribe: vi.fn().mockRejectedValue(subscribeError),
|
|
478
|
-
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
479
|
-
},
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
connectViaSubscribe({ client, mockSocket })
|
|
483
|
-
|
|
484
|
-
client.subscribe({
|
|
485
|
-
channel: 'prices',
|
|
486
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
487
|
-
onMessage: vi.fn(),
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
await flushMicrotasks()
|
|
491
|
-
|
|
492
|
-
expect(onError).toHaveBeenCalledWith(subscribeError)
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
it('socket error calls onError', () => {
|
|
496
|
-
const onError = vi.fn()
|
|
497
|
-
const { client, mockSocket } = createTestClient({ onError })
|
|
498
|
-
|
|
499
|
-
connectViaSubscribe({ client, mockSocket })
|
|
500
|
-
|
|
501
|
-
mockSocket.simulateError('Connection failed')
|
|
502
|
-
|
|
503
|
-
expect(onError).toHaveBeenCalledWith(expect.any(Error))
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
it('parseMessage throws calls onError', () => {
|
|
507
|
-
const onError = vi.fn()
|
|
508
|
-
const parseError = new Error('Parse failed')
|
|
509
|
-
|
|
510
|
-
const { client, mockSocket } = createTestClient({
|
|
511
|
-
onError,
|
|
512
|
-
parseMessage: () => {
|
|
513
|
-
throw parseError
|
|
514
|
-
},
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
connectViaSubscribe({ client, mockSocket })
|
|
518
|
-
|
|
519
|
-
// This message will trigger parseMessage
|
|
520
|
-
mockSocket.simulateMessage({ channel: 'prices', key: 'prices:token-1', data: { data: 'test' } })
|
|
521
|
-
|
|
522
|
-
expect(onError).toHaveBeenCalledWith(parseError)
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
it('callback throws calls onError but other callbacks still invoked', async () => {
|
|
526
|
-
const onError = vi.fn()
|
|
527
|
-
const { client, mockSocket } = createTestClient({ onError })
|
|
528
|
-
const messages: TestMessage[] = []
|
|
529
|
-
const callbackError = new Error('Callback error')
|
|
530
|
-
|
|
531
|
-
connectViaSubscribe({ client, mockSocket })
|
|
532
|
-
|
|
533
|
-
client.subscribe({
|
|
534
|
-
channel: 'prices',
|
|
535
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
536
|
-
onMessage: () => {
|
|
537
|
-
throw callbackError
|
|
538
|
-
},
|
|
539
|
-
})
|
|
540
|
-
client.subscribe({
|
|
541
|
-
channel: 'prices',
|
|
542
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
543
|
-
onMessage: (m) => messages.push(m),
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
await flushMicrotasks()
|
|
547
|
-
|
|
548
|
-
mockSocket.simulateMessage({
|
|
549
|
-
channel: 'prices',
|
|
550
|
-
key: 'prices:token-1',
|
|
551
|
-
data: { data: 'test' },
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
expect(onError).toHaveBeenCalledWith(callbackError)
|
|
555
|
-
expect(messages).toEqual([{ data: 'test' }])
|
|
556
|
-
})
|
|
557
|
-
})
|
|
558
|
-
|
|
559
|
-
describe('status callbacks', () => {
|
|
560
|
-
it('onStatusChange receives all transitions', async () => {
|
|
561
|
-
const { client, mockSocket } = createTestClient()
|
|
562
|
-
const statusChanges: ConnectionStatus[] = []
|
|
563
|
-
|
|
564
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
565
|
-
|
|
566
|
-
// Subscribe triggers connection
|
|
567
|
-
const unsub1 = client.subscribe({
|
|
568
|
-
channel: 'prices',
|
|
569
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
570
|
-
onMessage: vi.fn(),
|
|
571
|
-
})
|
|
572
|
-
mockSocket.simulateOpen()
|
|
573
|
-
mockSocket.simulateClose()
|
|
574
|
-
mockSocket.simulateOpen()
|
|
575
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
576
|
-
await flushMicrotasks()
|
|
577
|
-
|
|
578
|
-
// Last unsubscribe triggers disconnect (wasConnected=false before close,
|
|
579
|
-
// so no spurious 'reconnecting')
|
|
580
|
-
unsub1()
|
|
581
|
-
|
|
582
|
-
expect(statusChanges).toEqual(['connecting', 'connected', 'reconnecting', 'connected', 'disconnected'])
|
|
583
|
-
})
|
|
584
|
-
|
|
585
|
-
it('onConnectionEstablished fires when connectionId received', () => {
|
|
586
|
-
const { client, mockSocket } = createTestClient()
|
|
587
|
-
const connectionIds: string[] = []
|
|
588
|
-
|
|
589
|
-
client.onConnectionEstablished((id) => connectionIds.push(id))
|
|
590
|
-
|
|
591
|
-
// Subscribe triggers connection
|
|
592
|
-
client.subscribe({
|
|
593
|
-
channel: 'prices',
|
|
594
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
595
|
-
onMessage: vi.fn(),
|
|
596
|
-
})
|
|
597
|
-
mockSocket.simulateOpen()
|
|
598
|
-
|
|
599
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-1' })
|
|
600
|
-
mockSocket.simulateClose()
|
|
601
|
-
mockSocket.simulateOpen()
|
|
602
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-2' })
|
|
603
|
-
|
|
604
|
-
expect(connectionIds).toEqual(['conn-1', 'conn-2'])
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
it('unsubscribe from status callback works', () => {
|
|
608
|
-
const { client, mockSocket } = createTestClient()
|
|
609
|
-
const statusChanges: ConnectionStatus[] = []
|
|
610
|
-
|
|
611
|
-
const unsub = client.onStatusChange((s) => statusChanges.push(s))
|
|
612
|
-
|
|
613
|
-
client.subscribe({
|
|
614
|
-
channel: 'prices',
|
|
615
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
616
|
-
onMessage: vi.fn(),
|
|
617
|
-
})
|
|
618
|
-
|
|
619
|
-
unsub()
|
|
620
|
-
|
|
621
|
-
mockSocket.simulateOpen()
|
|
622
|
-
|
|
623
|
-
expect(statusChanges).toEqual(['connecting'])
|
|
624
|
-
})
|
|
625
|
-
|
|
626
|
-
it('unsubscribe from connection callback works', () => {
|
|
627
|
-
const { client, mockSocket } = createTestClient()
|
|
628
|
-
const connectionIds: string[] = []
|
|
629
|
-
|
|
630
|
-
const unsub = client.onConnectionEstablished((id) => connectionIds.push(id))
|
|
631
|
-
|
|
632
|
-
client.subscribe({
|
|
633
|
-
channel: 'prices',
|
|
634
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
635
|
-
onMessage: vi.fn(),
|
|
636
|
-
})
|
|
637
|
-
mockSocket.simulateOpen()
|
|
638
|
-
|
|
639
|
-
unsub()
|
|
640
|
-
|
|
641
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-1' })
|
|
642
|
-
|
|
643
|
-
expect(connectionIds).toEqual([])
|
|
644
|
-
})
|
|
645
|
-
})
|
|
646
|
-
|
|
647
|
-
describe('full lifecycle', () => {
|
|
648
|
-
it('complete subscription lifecycle from subscribe to last-unsubscribe disconnect', async () => {
|
|
649
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
650
|
-
const statusChanges: ConnectionStatus[] = []
|
|
651
|
-
const messages: TestMessage[] = []
|
|
652
|
-
|
|
653
|
-
client.onStatusChange((s) => statusChanges.push(s))
|
|
654
|
-
|
|
655
|
-
// Subscribe triggers lazy connect
|
|
656
|
-
const unsub = client.subscribe({
|
|
657
|
-
channel: 'prices',
|
|
658
|
-
params: { channel: 'prices', id: 'ETH' },
|
|
659
|
-
onMessage: (m) => messages.push(m),
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
expect(statusChanges).toEqual(['connecting'])
|
|
663
|
-
|
|
664
|
-
mockSocket.simulateOpen()
|
|
665
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
666
|
-
|
|
667
|
-
expect(statusChanges).toEqual(['connecting', 'connected'])
|
|
668
|
-
expect(client.getConnectionId()).toBe('conn-123')
|
|
669
|
-
|
|
670
|
-
await flushMicrotasks()
|
|
671
|
-
|
|
672
|
-
// resubscribeAll fires because there are active subs when connectionId is received
|
|
673
|
-
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'ETH' }])
|
|
674
|
-
|
|
675
|
-
mockSocket.simulateMessage({
|
|
676
|
-
channel: 'prices',
|
|
677
|
-
key: 'prices:ETH',
|
|
678
|
-
data: { data: 'price', price: 3000 },
|
|
679
|
-
})
|
|
680
|
-
expect(messages).toEqual([{ data: 'price', price: 3000 }])
|
|
681
|
-
|
|
682
|
-
unsub()
|
|
683
|
-
|
|
684
|
-
// Last unsubscribe triggers disconnect
|
|
685
|
-
expect(statusChanges).toContain('disconnected')
|
|
686
|
-
})
|
|
687
|
-
})
|
|
688
|
-
|
|
689
|
-
describe('subscribe without onMessage', () => {
|
|
690
|
-
it('subscribe without onMessage still triggers connection and REST subscribe', async () => {
|
|
691
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
692
|
-
|
|
693
|
-
client.subscribe({
|
|
694
|
-
channel: 'prices',
|
|
695
|
-
params: { channel: 'prices', id: 'token-1' },
|
|
696
|
-
})
|
|
697
|
-
|
|
698
|
-
// Should still trigger connection
|
|
699
|
-
expect(client.getConnectionStatus()).toBe('connecting')
|
|
700
|
-
|
|
701
|
-
mockSocket.simulateOpen()
|
|
702
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
703
|
-
|
|
704
|
-
await flushMicrotasks()
|
|
705
|
-
|
|
706
|
-
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
707
|
-
})
|
|
708
|
-
})
|
|
709
|
-
|
|
710
|
-
describe('onRawMessage callback', () => {
|
|
711
|
-
it('onRawMessage receives all parsed messages', () => {
|
|
712
|
-
const onRawMessage = vi.fn()
|
|
713
|
-
const { client, mockSocket } = createTestClient({ onRawMessage })
|
|
714
|
-
|
|
715
|
-
connectViaSubscribe({ client, mockSocket })
|
|
716
|
-
|
|
717
|
-
mockSocket.simulateMessage({ channel: 'prices', key: 'prices:ETH', data: { data: 'test' } })
|
|
718
|
-
|
|
719
|
-
// onRawMessage called for: connection message (from connectViaSubscribe) + the data message
|
|
720
|
-
expect(onRawMessage).toHaveBeenCalledWith({ type: 'connected', connectionId: 'conn-123' })
|
|
721
|
-
expect(onRawMessage).toHaveBeenCalledWith({ channel: 'prices', key: 'prices:ETH', data: { data: 'test' } })
|
|
722
|
-
})
|
|
723
|
-
})
|
|
724
|
-
|
|
725
|
-
describe('session refresh timer', () => {
|
|
726
|
-
beforeEach(() => {
|
|
727
|
-
vi.useFakeTimers()
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
afterEach(() => {
|
|
731
|
-
vi.useRealTimers()
|
|
732
|
-
})
|
|
733
|
-
|
|
734
|
-
it('starts timer on connect and calls refreshSession at interval', async () => {
|
|
735
|
-
const { client, mockSocket, handler } = createTestClient({
|
|
736
|
-
sessionRefreshIntervalMs: 5000,
|
|
737
|
-
})
|
|
738
|
-
|
|
739
|
-
connectViaSubscribe({ client, mockSocket })
|
|
740
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
741
|
-
|
|
742
|
-
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
743
|
-
|
|
744
|
-
await vi.advanceTimersByTimeAsync(5000)
|
|
745
|
-
|
|
746
|
-
expect(handler.refreshSession).toHaveBeenCalledTimes(1)
|
|
747
|
-
|
|
748
|
-
await vi.advanceTimersByTimeAsync(5000)
|
|
749
|
-
|
|
750
|
-
expect(handler.refreshSession).toHaveBeenCalledTimes(2)
|
|
751
|
-
})
|
|
752
|
-
|
|
753
|
-
it('stops timer on disconnect (last unsubscribe)', async () => {
|
|
754
|
-
const { client, mockSocket, handler } = createTestClient({
|
|
755
|
-
sessionRefreshIntervalMs: 5000,
|
|
756
|
-
})
|
|
757
|
-
|
|
758
|
-
const unsub = connectViaSubscribe({ client, mockSocket })
|
|
759
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
760
|
-
|
|
761
|
-
unsub()
|
|
762
|
-
|
|
763
|
-
await vi.advanceTimersByTimeAsync(10000)
|
|
764
|
-
|
|
765
|
-
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
766
|
-
})
|
|
767
|
-
|
|
768
|
-
it('stops timer on socket close', async () => {
|
|
769
|
-
const { client, mockSocket, handler } = createTestClient({
|
|
770
|
-
sessionRefreshIntervalMs: 5000,
|
|
771
|
-
})
|
|
772
|
-
|
|
773
|
-
connectViaSubscribe({ client, mockSocket })
|
|
774
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
775
|
-
|
|
776
|
-
mockSocket.simulateClose()
|
|
777
|
-
|
|
778
|
-
await vi.advanceTimersByTimeAsync(10000)
|
|
779
|
-
|
|
780
|
-
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
781
|
-
})
|
|
782
|
-
|
|
783
|
-
it('restarts timer on reconnect', async () => {
|
|
784
|
-
const { client, mockSocket, handler } = createTestClient({
|
|
785
|
-
sessionRefreshIntervalMs: 5000,
|
|
786
|
-
})
|
|
787
|
-
|
|
788
|
-
connectViaSubscribe({ client, mockSocket })
|
|
789
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
790
|
-
|
|
791
|
-
// Simulate reconnect
|
|
792
|
-
mockSocket.simulateClose()
|
|
793
|
-
mockSocket.simulateOpen()
|
|
794
|
-
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-456' })
|
|
795
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
796
|
-
|
|
797
|
-
handler.refreshSession.mockClear()
|
|
798
|
-
|
|
799
|
-
await vi.advanceTimersByTimeAsync(5000)
|
|
800
|
-
|
|
801
|
-
expect(handler.refreshSession).toHaveBeenCalledTimes(1)
|
|
802
|
-
})
|
|
803
|
-
|
|
804
|
-
it('does not start timer without sessionRefreshIntervalMs option', async () => {
|
|
805
|
-
const { client, mockSocket, handler } = createTestClient()
|
|
806
|
-
|
|
807
|
-
connectViaSubscribe({ client, mockSocket })
|
|
808
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
809
|
-
|
|
810
|
-
await vi.advanceTimersByTimeAsync(60000)
|
|
811
|
-
|
|
812
|
-
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
813
|
-
})
|
|
814
|
-
|
|
815
|
-
it('does not start timer without handler refreshSession method', async () => {
|
|
816
|
-
const { client, mockSocket } = createTestClient({
|
|
817
|
-
sessionRefreshIntervalMs: 5000,
|
|
818
|
-
subscriptionHandler: {
|
|
819
|
-
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
820
|
-
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
821
|
-
},
|
|
822
|
-
})
|
|
823
|
-
|
|
824
|
-
connectViaSubscribe({ client, mockSocket })
|
|
825
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
826
|
-
|
|
827
|
-
// No error thrown, timer simply doesn't start
|
|
828
|
-
await vi.advanceTimersByTimeAsync(10000)
|
|
829
|
-
})
|
|
830
|
-
})
|
|
831
|
-
})
|