@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.
@@ -0,0 +1,283 @@
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
+ * - Eager cancellation: subscribe cancels a pending unsubscribe for the same key (and vice versa),
14
+ * so navigations that unmount and remount the same subscription produce zero API calls
15
+ * - Auto-resubscribe: On reconnect, resubscribes all active subscriptions with new connectionId
16
+ * - Deduplication: Multiple subscribers to same params share one subscription
17
+ * - Message routing: Routes incoming messages to appropriate callbacks
18
+ */
19
+ export class SubscriptionManager<TParams, TMessage> {
20
+ private subscriptions = new Map<string, SubscriptionEntry<TParams, TMessage>>()
21
+ private readonly handler: SubscriptionManagerOptions<TParams>['handler']
22
+ private readonly createKey: SubscriptionManagerOptions<TParams>['createKey']
23
+ private readonly onError?: SubscriptionManagerOptions<TParams>['onError']
24
+ private readonly onSubscriptionCountChange?: SubscriptionManagerOptions<TParams>['onSubscriptionCountChange']
25
+ private connectionId: string | null = null
26
+
27
+ private pendingSubscribes = new Map<string, TParams>()
28
+ private pendingUnsubscribes = new Map<string, TParams>()
29
+ private subscribeFlushScheduled = false
30
+ private unsubscribeFlushScheduled = false
31
+
32
+ constructor(options: SubscriptionManagerOptions<TParams>) {
33
+ this.handler = options.handler
34
+ this.createKey = options.createKey
35
+ this.onError = options.onError
36
+ this.onSubscriptionCountChange = options.onSubscriptionCountChange
37
+ }
38
+
39
+ /**
40
+ * Set the current connection ID. Called when connection is established.
41
+ */
42
+ setConnectionId(connectionId: string | null): void {
43
+ this.connectionId = connectionId
44
+ }
45
+
46
+ /**
47
+ * Get the current connection ID.
48
+ */
49
+ getConnectionId(): string | null {
50
+ return this.connectionId
51
+ }
52
+
53
+ /**
54
+ * Subscribe to a channel with given params.
55
+ * Synchronous — returns an unsubscribe function immediately.
56
+ * The actual REST subscribe call is batched via queueMicrotask.
57
+ */
58
+ subscribe(input: SubscribeInput<TParams, TMessage>): () => void {
59
+ const { channel, params, callback } = input
60
+ const key = this.createKey(channel, params)
61
+ let entry = this.subscriptions.get(key)
62
+ let isNewEntry = false
63
+
64
+ if (entry) {
65
+ entry.subscriberCount++
66
+ if (callback) {
67
+ entry.callbacks.add(callback)
68
+ }
69
+ } else {
70
+ isNewEntry = true
71
+ entry = {
72
+ channel,
73
+ params,
74
+ callbacks: new Set(callback ? [callback] : []),
75
+ subscriberCount: 1,
76
+ }
77
+ this.subscriptions.set(key, entry)
78
+
79
+ if (this.pendingUnsubscribes.has(key)) {
80
+ // Re-subscribing to a key that was just unsubscribed in this microtask window.
81
+ // Cancel the pending unsubscribe — the server still has this subscription.
82
+ this.pendingUnsubscribes.delete(key)
83
+ } else {
84
+ // Genuinely new subscription — queue the REST subscribe call
85
+ this.pendingSubscribes.set(key, params)
86
+ this.scheduleSubscribeFlush()
87
+ }
88
+ }
89
+
90
+ if (isNewEntry) {
91
+ this.onSubscriptionCountChange?.(this.subscriptions.size)
92
+ }
93
+
94
+ let unsubscribed = false
95
+ return () => {
96
+ if (unsubscribed) {
97
+ return
98
+ }
99
+ unsubscribed = true
100
+ this.handleUnsubscribe(key, callback)
101
+ }
102
+ }
103
+
104
+ private handleUnsubscribe(key: string, callback: ((message: TMessage) => void) | undefined): void {
105
+ const entry = this.subscriptions.get(key)
106
+ if (!entry) {
107
+ return
108
+ }
109
+
110
+ if (callback) {
111
+ entry.callbacks.delete(callback)
112
+ }
113
+
114
+ entry.subscriberCount--
115
+
116
+ // If no more subscribers, remove subscription entirely
117
+ if (entry.subscriberCount === 0) {
118
+ this.subscriptions.delete(key)
119
+
120
+ if (this.pendingSubscribes.has(key)) {
121
+ // Unsubscribing a key that was just subscribed in this microtask window.
122
+ // Cancel the pending subscribe — no need to tell the server about either.
123
+ this.pendingSubscribes.delete(key)
124
+ } else {
125
+ // Queue the REST unsubscribe call
126
+ this.pendingUnsubscribes.set(key, entry.params)
127
+ this.scheduleUnsubscribeFlush()
128
+ }
129
+
130
+ this.onSubscriptionCountChange?.(this.subscriptions.size)
131
+ }
132
+ }
133
+
134
+ private scheduleSubscribeFlush(): void {
135
+ if (!this.subscribeFlushScheduled) {
136
+ this.subscribeFlushScheduled = true
137
+ queueMicrotask(() => {
138
+ const params = [...this.pendingSubscribes.values()]
139
+ this.pendingSubscribes.clear()
140
+ this.subscribeFlushScheduled = false
141
+ if (params.length > 0 && this.connectionId) {
142
+ this.executeBatchSubscribe(params)
143
+ }
144
+ })
145
+ }
146
+ }
147
+
148
+ private scheduleUnsubscribeFlush(): void {
149
+ if (!this.unsubscribeFlushScheduled) {
150
+ this.unsubscribeFlushScheduled = true
151
+ queueMicrotask(() => {
152
+ const params = [...this.pendingUnsubscribes.values()]
153
+ this.pendingUnsubscribes.clear()
154
+ this.unsubscribeFlushScheduled = false
155
+ if (params.length > 0 && this.connectionId) {
156
+ this.executeBatchUnsubscribe(params)
157
+ }
158
+ })
159
+ }
160
+ }
161
+
162
+ private executeBatchSubscribe(params: TParams[]): void {
163
+ const { connectionId } = this
164
+ if (!connectionId) {
165
+ return
166
+ }
167
+ if (this.handler.subscribeBatch) {
168
+ this.handler.subscribeBatch(connectionId, params).catch((error) => {
169
+ this.onError?.(error, 'subscribe')
170
+ })
171
+ } else {
172
+ Promise.all(params.map((p) => this.handler.subscribe(connectionId, p))).catch((error) => {
173
+ this.onError?.(error, 'subscribe')
174
+ })
175
+ }
176
+ }
177
+
178
+ private executeBatchUnsubscribe(params: TParams[]): void {
179
+ const { connectionId } = this
180
+ if (!connectionId) {
181
+ return
182
+ }
183
+ if (this.handler.unsubscribeBatch) {
184
+ this.handler.unsubscribeBatch(connectionId, params).catch((error) => {
185
+ this.onError?.(error, 'unsubscribe')
186
+ })
187
+ } else {
188
+ Promise.all(params.map((p) => this.handler.unsubscribe(connectionId, p))).catch((error) => {
189
+ this.onError?.(error, 'unsubscribe')
190
+ })
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Resubscribe all active subscriptions with a new connection ID.
196
+ * Called after reconnection. Uses subscribeBatch when available.
197
+ */
198
+ async resubscribeAll(connectionId: string): Promise<void> {
199
+ this.connectionId = connectionId
200
+
201
+ const allParams = Array.from(this.subscriptions.values()).map((entry) => entry.params)
202
+ if (allParams.length === 0) {
203
+ return
204
+ }
205
+
206
+ if (this.handler.subscribeBatch) {
207
+ try {
208
+ await this.handler.subscribeBatch(connectionId, allParams)
209
+ } catch (error) {
210
+ this.onError?.(error, 'resubscribe')
211
+ }
212
+ } else {
213
+ const subscribePromises = allParams.map((params) =>
214
+ this.handler.subscribe(connectionId, params).catch((error) => {
215
+ this.onError?.(error, 'resubscribe')
216
+ }),
217
+ )
218
+ await Promise.all(subscribePromises)
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Dispatch an incoming message to appropriate callbacks.
224
+ */
225
+ dispatch(key: string, message: TMessage): void {
226
+ const entry = this.subscriptions.get(key)
227
+ if (!entry) {
228
+ return
229
+ }
230
+
231
+ for (const callback of entry.callbacks) {
232
+ try {
233
+ callback(message)
234
+ } catch (error) {
235
+ this.onError?.(error, 'dispatch')
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Get all active subscriptions.
242
+ */
243
+ getActiveSubscriptions(): Array<{ channel: string; params: TParams; subscriberCount: number }> {
244
+ return Array.from(this.subscriptions.entries()).map(([, entry]) => ({
245
+ channel: entry.channel,
246
+ params: entry.params,
247
+ subscriberCount: entry.subscriberCount,
248
+ }))
249
+ }
250
+
251
+ /**
252
+ * Check if there are any active subscriptions.
253
+ */
254
+ hasActiveSubscriptions(): boolean {
255
+ return this.subscriptions.size > 0
256
+ }
257
+
258
+ /**
259
+ * Clear all subscriptions without calling unsubscribe API.
260
+ * Used on disconnect.
261
+ */
262
+ clear(): void {
263
+ this.subscriptions.clear()
264
+ this.pendingSubscribes.clear()
265
+ this.pendingUnsubscribes.clear()
266
+ this.connectionId = null
267
+ }
268
+
269
+ /**
270
+ * Refresh the session if the handler supports it.
271
+ */
272
+ async refreshSession(): Promise<void> {
273
+ if (!this.connectionId || !this.handler.refreshSession) {
274
+ return
275
+ }
276
+
277
+ try {
278
+ await this.handler.refreshSession(this.connectionId)
279
+ } catch (error) {
280
+ this.onError?.(error, 'refreshSession')
281
+ }
282
+ }
283
+ }
@@ -0,0 +1,21 @@
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
+ subscriberCount: number
8
+ }
9
+
10
+ export interface SubscriptionManagerOptions<TParams> {
11
+ handler: SubscriptionHandler<TParams>
12
+ createKey: (channel: string, params: TParams) => string
13
+ onError?: (error: unknown, operation: string) => void
14
+ onSubscriptionCountChange?: (count: number) => void
15
+ }
16
+
17
+ export interface SubscribeInput<TParams, TMessage> {
18
+ channel: string
19
+ params: TParams
20
+ callback?: (message: TMessage) => void
21
+ }
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
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
+ }
@@ -0,0 +1,48 @@
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
+ })
@@ -0,0 +1,15 @@
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
+ }
package/tsconfig.json CHANGED
@@ -1,12 +1,28 @@
1
1
  {
2
2
  "extends": "../../config/tsconfig/app.json",
3
- "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "src/global.d.ts"],
4
- "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"],
3
+ "include": [
4
+ "src/**/*.ts",
5
+ "src/**/*.tsx",
6
+ "src/**/*.json",
7
+ "src/global.d.ts"
8
+ ],
9
+ "exclude": [
10
+ "src/**/*.spec.ts",
11
+ "src/**/*.spec.tsx",
12
+ "src/**/*.test.ts",
13
+ "src/**/*.test.tsx"
14
+ ],
5
15
  "compilerOptions": {
6
16
  "noEmit": false,
7
17
  "emitDeclarationOnly": true,
8
18
  "types": ["node", "vitest/globals"],
9
- "paths": {}
19
+ "paths": {
20
+ "@luxexchange/websocket/src/*": ["./src/*"]
21
+ }
10
22
  },
11
- "references": []
23
+ "references": [
24
+ {
25
+ "path": "../eslint-config"
26
+ }
27
+ ]
12
28
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "preserveSymlinks": true
5
+ },
6
+ "include": ["**/*.ts", "**/*.tsx", "**/*.json"],
7
+ "exclude": ["node_modules"]
8
+ }
@@ -0,0 +1,17 @@
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
+ })