@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.
@@ -1,274 +0,0 @@
1
- import type {
2
- SubscribeInput,
3
- SubscriptionEntry,
4
- SubscriptionManagerOptions,
5
- } from '@luxexchange/websocket/src/subscriptions/types'
6
-
7
- /**
8
- * Manages subscription lifecycle with reference counting and microtask batching.
9
- *
10
- * Key features:
11
- * - Reference counting: Only calls REST subscribe on first subscriber, unsubscribe on last
12
- * - Microtask batching: Coalesces subscribe/unsubscribe calls within the same microtask
13
- * - Auto-resubscribe: On reconnect, resubscribes all active subscriptions with new connectionId
14
- * - Deduplication: Multiple subscribers to same params share one subscription
15
- * - Message routing: Routes incoming messages to appropriate callbacks
16
- */
17
- export class SubscriptionManager<TParams, TMessage> {
18
- private subscriptions = new Map<string, SubscriptionEntry<TParams, TMessage>>()
19
- private readonly handler: SubscriptionManagerOptions<TParams>['handler']
20
- private readonly createKey: SubscriptionManagerOptions<TParams>['createKey']
21
- private readonly onError?: SubscriptionManagerOptions<TParams>['onError']
22
- private readonly onSubscriptionCountChange?: SubscriptionManagerOptions<TParams>['onSubscriptionCountChange']
23
- private connectionId: string | null = null
24
-
25
- private pendingSubscribes = new Map<string, TParams>()
26
- private pendingUnsubscribes = new Map<string, TParams>()
27
- private subscribeFlushScheduled = false
28
- private unsubscribeFlushScheduled = false
29
-
30
- constructor(options: SubscriptionManagerOptions<TParams>) {
31
- this.handler = options.handler
32
- this.createKey = options.createKey
33
- this.onError = options.onError
34
- this.onSubscriptionCountChange = options.onSubscriptionCountChange
35
- }
36
-
37
- /**
38
- * Set the current connection ID. Called when connection is established.
39
- */
40
- setConnectionId(connectionId: string | null): void {
41
- this.connectionId = connectionId
42
- }
43
-
44
- /**
45
- * Get the current connection ID.
46
- */
47
- getConnectionId(): string | null {
48
- return this.connectionId
49
- }
50
-
51
- /**
52
- * Subscribe to a channel with given params.
53
- * Synchronous — returns an unsubscribe function immediately.
54
- * The actual REST subscribe call is batched via queueMicrotask.
55
- */
56
- subscribe(input: SubscribeInput<TParams, TMessage>): () => void {
57
- const { channel, params, callback } = input
58
- const key = this.createKey(channel, params)
59
- let entry = this.subscriptions.get(key)
60
- let isNewEntry = false
61
-
62
- if (entry) {
63
- if (callback) {
64
- entry.callbacks.add(callback)
65
- }
66
- } else {
67
- isNewEntry = true
68
- entry = {
69
- channel,
70
- params,
71
- callbacks: new Set(callback ? [callback] : []),
72
- }
73
- this.subscriptions.set(key, entry)
74
-
75
- // Queue the REST subscribe call
76
- this.pendingSubscribes.set(key, params)
77
- this.scheduleSubscribeFlush()
78
- }
79
-
80
- if (isNewEntry) {
81
- this.onSubscriptionCountChange?.(this.subscriptions.size)
82
- }
83
-
84
- return () => {
85
- this.handleUnsubscribe(key, callback)
86
- }
87
- }
88
-
89
- private handleUnsubscribe(key: string, callback: ((message: TMessage) => void) | undefined): void {
90
- const entry = this.subscriptions.get(key)
91
- if (!entry) {
92
- return
93
- }
94
-
95
- if (callback) {
96
- entry.callbacks.delete(callback)
97
- }
98
-
99
- // If no more callbacks, remove subscription entirely
100
- if (entry.callbacks.size === 0) {
101
- this.subscriptions.delete(key)
102
-
103
- // Queue the REST unsubscribe call
104
- this.pendingUnsubscribes.set(key, entry.params)
105
- this.scheduleUnsubscribeFlush()
106
-
107
- this.onSubscriptionCountChange?.(this.subscriptions.size)
108
- }
109
- }
110
-
111
- private scheduleSubscribeFlush(): void {
112
- if (!this.subscribeFlushScheduled) {
113
- this.subscribeFlushScheduled = true
114
- queueMicrotask(() => {
115
- // Cancel out keys that appear in both pending subscribe and unsubscribe
116
- for (const key of this.pendingUnsubscribes.keys()) {
117
- if (this.pendingSubscribes.has(key)) {
118
- this.pendingSubscribes.delete(key)
119
- this.pendingUnsubscribes.delete(key)
120
- }
121
- }
122
- const params = [...this.pendingSubscribes.values()]
123
- this.pendingSubscribes.clear()
124
- this.subscribeFlushScheduled = false
125
- if (params.length > 0 && this.connectionId) {
126
- this.executeBatchSubscribe(params)
127
- }
128
- })
129
- }
130
- }
131
-
132
- private scheduleUnsubscribeFlush(): void {
133
- if (!this.unsubscribeFlushScheduled) {
134
- this.unsubscribeFlushScheduled = true
135
- queueMicrotask(() => {
136
- // Cancel out keys that appear in both pending subscribe and unsubscribe
137
- for (const key of this.pendingSubscribes.keys()) {
138
- if (this.pendingUnsubscribes.has(key)) {
139
- this.pendingUnsubscribes.delete(key)
140
- this.pendingSubscribes.delete(key)
141
- }
142
- }
143
- const params = [...this.pendingUnsubscribes.values()]
144
- this.pendingUnsubscribes.clear()
145
- this.unsubscribeFlushScheduled = false
146
- if (params.length > 0 && this.connectionId) {
147
- this.executeBatchUnsubscribe(params)
148
- }
149
- })
150
- }
151
- }
152
-
153
- private executeBatchSubscribe(params: TParams[]): void {
154
- const { connectionId } = this
155
- if (!connectionId) {
156
- return
157
- }
158
- if (this.handler.subscribeBatch) {
159
- this.handler.subscribeBatch(connectionId, params).catch((error) => {
160
- this.onError?.(error, 'subscribe')
161
- })
162
- } else {
163
- Promise.all(params.map((p) => this.handler.subscribe(connectionId, p))).catch((error) => {
164
- this.onError?.(error, 'subscribe')
165
- })
166
- }
167
- }
168
-
169
- private executeBatchUnsubscribe(params: TParams[]): void {
170
- const { connectionId } = this
171
- if (!connectionId) {
172
- return
173
- }
174
- if (this.handler.unsubscribeBatch) {
175
- this.handler.unsubscribeBatch(connectionId, params).catch((error) => {
176
- this.onError?.(error, 'unsubscribe')
177
- })
178
- } else {
179
- Promise.all(params.map((p) => this.handler.unsubscribe(connectionId, p))).catch((error) => {
180
- this.onError?.(error, 'unsubscribe')
181
- })
182
- }
183
- }
184
-
185
- /**
186
- * Resubscribe all active subscriptions with a new connection ID.
187
- * Called after reconnection. Uses subscribeBatch when available.
188
- */
189
- async resubscribeAll(connectionId: string): Promise<void> {
190
- this.connectionId = connectionId
191
-
192
- const allParams = Array.from(this.subscriptions.values()).map((entry) => entry.params)
193
- if (allParams.length === 0) {
194
- return
195
- }
196
-
197
- if (this.handler.subscribeBatch) {
198
- try {
199
- await this.handler.subscribeBatch(connectionId, allParams)
200
- } catch (error) {
201
- this.onError?.(error, 'resubscribe')
202
- }
203
- } else {
204
- const subscribePromises = allParams.map((params) =>
205
- this.handler.subscribe(connectionId, params).catch((error) => {
206
- this.onError?.(error, 'resubscribe')
207
- }),
208
- )
209
- await Promise.all(subscribePromises)
210
- }
211
- }
212
-
213
- /**
214
- * Dispatch an incoming message to appropriate callbacks.
215
- */
216
- dispatch(key: string, message: TMessage): void {
217
- const entry = this.subscriptions.get(key)
218
- if (!entry) {
219
- return
220
- }
221
-
222
- for (const callback of entry.callbacks) {
223
- try {
224
- callback(message)
225
- } catch (error) {
226
- this.onError?.(error, 'dispatch')
227
- }
228
- }
229
- }
230
-
231
- /**
232
- * Get all active subscriptions.
233
- */
234
- getActiveSubscriptions(): Array<{ channel: string; params: TParams; subscriberCount: number }> {
235
- return Array.from(this.subscriptions.entries()).map(([, entry]) => ({
236
- channel: entry.channel,
237
- params: entry.params,
238
- subscriberCount: entry.callbacks.size,
239
- }))
240
- }
241
-
242
- /**
243
- * Check if there are any active subscriptions.
244
- */
245
- hasActiveSubscriptions(): boolean {
246
- return this.subscriptions.size > 0
247
- }
248
-
249
- /**
250
- * Clear all subscriptions without calling unsubscribe API.
251
- * Used on disconnect.
252
- */
253
- clear(): void {
254
- this.subscriptions.clear()
255
- this.pendingSubscribes.clear()
256
- this.pendingUnsubscribes.clear()
257
- this.connectionId = null
258
- }
259
-
260
- /**
261
- * Refresh the session if the handler supports it.
262
- */
263
- async refreshSession(): Promise<void> {
264
- if (!this.connectionId || !this.handler.refreshSession) {
265
- return
266
- }
267
-
268
- try {
269
- await this.handler.refreshSession(this.connectionId)
270
- } catch (error) {
271
- this.onError?.(error, 'refreshSession')
272
- }
273
- }
274
- }
@@ -1,20 +0,0 @@
1
- import type { SubscriptionHandler } from '@luxexchange/websocket/src/types'
2
-
3
- export interface SubscriptionEntry<TParams, TMessage> {
4
- channel: string
5
- params: TParams
6
- callbacks: Set<(message: TMessage) => void>
7
- }
8
-
9
- export interface SubscriptionManagerOptions<TParams> {
10
- handler: SubscriptionHandler<TParams>
11
- createKey: (channel: string, params: TParams) => string
12
- onError?: (error: unknown, operation: string) => void
13
- onSubscriptionCountChange?: (count: number) => void
14
- }
15
-
16
- export interface SubscribeInput<TParams, TMessage> {
17
- channel: string
18
- params: TParams
19
- callback?: (message: TMessage) => void
20
- }
package/src/types.ts DELETED
@@ -1,88 +0,0 @@
1
- /**
2
- * Core types for generic WebSocket client functionality
3
- */
4
-
5
- import type { ConnectionStore } from '@luxexchange/websocket/src/store/types'
6
-
7
- export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
8
-
9
- /**
10
- * Minimal interface for WebSocket-like objects.
11
- * Allows injection of mock WebSocket implementations for testing.
12
- */
13
- export interface WebSocketLike {
14
- readyState: number
15
- addEventListener(event: string, handler: (event: unknown) => void): void
16
- close(): void
17
- }
18
-
19
- /**
20
- * Options for creating a WebSocket connection.
21
- * These match the options expected by PartySocket.
22
- */
23
- export interface SocketFactoryOptions {
24
- maxReconnectionDelay: number
25
- minReconnectionDelay: number
26
- reconnectionDelayGrowFactor: number
27
- connectionTimeout: number
28
- maxRetries: number
29
- debug: boolean
30
- }
31
-
32
- export interface ConnectionConfig {
33
- url: string
34
- maxReconnectionDelay?: number
35
- minReconnectionDelay?: number
36
- connectionTimeout?: number
37
- maxRetries?: number
38
- debug?: boolean
39
- }
40
-
41
- /**
42
- * Subscription handler - consumers provide REST API implementation
43
- * for managing server-side subscription lifecycle
44
- */
45
- export interface SubscriptionHandler<TParams = unknown> {
46
- subscribe: (connectionId: string, params: TParams) => Promise<void>
47
- unsubscribe: (connectionId: string, params: TParams) => Promise<void>
48
- subscribeBatch?: (connectionId: string, params: TParams[]) => Promise<void>
49
- unsubscribeBatch?: (connectionId: string, params: TParams[]) => Promise<void>
50
- refreshSession?: (connectionId: string) => Promise<void>
51
- }
52
-
53
- export interface SubscriptionOptions<TParams, TMessage> {
54
- channel: string
55
- params: TParams
56
- onMessage?: (message: TMessage) => void
57
- }
58
-
59
- export interface WebSocketClient<TParams = unknown, TMessage = unknown> {
60
- isConnected: () => boolean
61
- getConnectionStatus: () => ConnectionStatus
62
- getConnectionId: () => string | null
63
- subscribe: (options: SubscriptionOptions<TParams, TMessage>) => () => void
64
- onStatusChange: (callback: (status: ConnectionStatus) => void) => () => void
65
- onConnectionEstablished: (callback: (connectionId: string) => void) => () => void
66
- }
67
-
68
- export interface CreateWebSocketClientOptions<TParams, TMessage> {
69
- config: ConnectionConfig
70
- connectionStore: ConnectionStore
71
- subscriptionHandler: SubscriptionHandler<TParams>
72
- parseMessage: (raw: unknown) => { channel: string; key: string; data: TMessage } | null
73
- parseConnectionMessage: (raw: unknown) => { connectionId: string } | null
74
- createSubscriptionKey: (channel: string, params: TParams) => string
75
- onError?: (error: unknown) => void
76
- onRawMessage?: (message: unknown) => void
77
- /**
78
- * Interval (ms) for automatically refreshing the server session.
79
- * When set, the client starts a timer on connect and stops it on disconnect.
80
- * Requires the subscriptionHandler to implement refreshSession.
81
- */
82
- sessionRefreshIntervalMs?: number
83
- /**
84
- * Optional factory for creating WebSocket instances.
85
- * Defaults to PartySocket. Primarily used for testing with mock WebSockets.
86
- */
87
- socketFactory?: (url: string, options: SocketFactoryOptions) => WebSocketLike
88
- }
@@ -1,48 +0,0 @@
1
- import { addJitter, getDefaultJitteredDelay } from '@luxexchange/websocket/src/utils/backoff'
2
- import { describe, expect, it, vi } from 'vitest'
3
-
4
- describe('backoff utilities', () => {
5
- describe('addJitter', () => {
6
- it('returns value in range [minDelay, minDelay + jitterRange]', () => {
7
- const minDelay = 1000
8
- const jitterRange = 4000
9
-
10
- // Run multiple times to test randomness
11
- for (let i = 0; i < 100; i++) {
12
- const result = addJitter(minDelay, jitterRange)
13
- expect(result).toBeGreaterThanOrEqual(minDelay)
14
- expect(result).toBeLessThan(minDelay + jitterRange)
15
- }
16
- })
17
-
18
- it('returns exact minDelay when Math.random returns 0', () => {
19
- vi.spyOn(Math, 'random').mockReturnValue(0)
20
-
21
- const result = addJitter(1000, 4000)
22
-
23
- expect(result).toBe(1000)
24
-
25
- vi.restoreAllMocks()
26
- })
27
-
28
- it('returns near max when Math.random returns near 1', () => {
29
- vi.spyOn(Math, 'random').mockReturnValue(0.999)
30
-
31
- const result = addJitter(1000, 4000)
32
-
33
- expect(result).toBeCloseTo(4996, 0)
34
-
35
- vi.restoreAllMocks()
36
- })
37
- })
38
-
39
- describe('getDefaultJitteredDelay', () => {
40
- it('returns value between 1000 and 5000', () => {
41
- for (let i = 0; i < 100; i++) {
42
- const result = getDefaultJitteredDelay()
43
- expect(result).toBeGreaterThanOrEqual(1000)
44
- expect(result).toBeLessThan(5000)
45
- }
46
- })
47
- })
48
- })
@@ -1,15 +0,0 @@
1
- /**
2
- * Add jitter to a delay to prevent thundering herd on reconnect.
3
- * Returns a value between minDelay and minDelay + jitterRange.
4
- */
5
- export function addJitter(minDelay: number, jitterRange: number): number {
6
- return minDelay + Math.random() * jitterRange
7
- }
8
-
9
- /**
10
- * Default jitter configuration for WebSocket reconnection.
11
- * Base delay of 1000ms with up to 4000ms additional jitter.
12
- */
13
- export function getDefaultJitteredDelay(): number {
14
- return addJitter(1000, 4000)
15
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "preserveSymlinks": true
5
- },
6
- "include": ["**/*.ts", "**/*.tsx", "**/*.json"],
7
- "exclude": ["node_modules"]
8
- }
package/vitest.config.ts DELETED
@@ -1,17 +0,0 @@
1
- import { defineConfig } from 'vitest/config'
2
-
3
- export default defineConfig({
4
- test: {
5
- environment: 'node',
6
- coverage: {
7
- exclude: [
8
- '**/__generated__/**',
9
- '**/node_modules/**',
10
- '**/dist/**',
11
- '**/*.config.*',
12
- '**/scripts/**',
13
- '**/.eslintrc.*',
14
- ],
15
- },
16
- },
17
- })