-
Notifications
You must be signed in to change notification settings - Fork 207
Expand file tree
/
Copy pathuseSocket.ts
More file actions
252 lines (223 loc) · 8.59 KB
/
Copy pathuseSocket.ts
File metadata and controls
252 lines (223 loc) · 8.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"use client";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { PriceData } from "@/types";
import { savePriceBatch } from "@/lib/priceStorage";
import { useErrorTimeout } from "./useErrorTimeout";
import { usePageVisibility } from "./usePageVisibility";
import { useRAFInterval } from "./useRAFInterval";
import type { AssetSymbol } from "@/config/assetSymbols";
import { WebSocketManager } from "@/utils/WebSocketManager";
export type PayoutStatus = {
transactionId: string;
status: "PROCESSING" | "DISPATCHED" | "DELIVERED" | "REJECTED";
error?: string;
};
export interface UseSocketOptions {
assetIds?: AssetSymbol[];
enableDeltaUpdates?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
errorTimeoutMs?: number;
}
export interface UseSocketReturn {
isConnected: boolean;
lastUpdate: PriceData | null;
payoutStatus: PayoutStatus | null;
error: string | null;
reconnectAttempts: number;
subscribeToAsset: (assetId: string) => void;
unsubscribeFromAsset: (assetId: string) => void;
disconnect: () => void;
reconnect: () => void;
}
// ---------------------------------------------------------------------------
// Internal hook — delegates all transport concerns to WebSocketManager so
// that this hook only manages per-consumer state (lastUpdate, isConnected).
// ---------------------------------------------------------------------------
function useSocketState(options: UseSocketOptions): UseSocketReturn {
const { assetIds = [], errorTimeoutMs = 5000 } = options;
const [isConnected, setIsConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState<PriceData | null>(null);
const [payoutStatus, setPayoutStatus] = useState<PayoutStatus | null>(null);
const { error, setError } = useErrorTimeout({ timeoutMs: errorTimeoutMs });
// Track which assets this consumer instance has subscribed to so that
// cleanup on unmount is scoped to only those assets.
const subscribedAssetsRef = useRef<Set<string>>(new Set(assetIds));
const isVisible = usePageVisibility();
// Batch pending WS message payloads; flushed by the RAF interval below so
// we never write state more than once per animation frame.
const pendingUpdatesRef = useRef<(PriceData | Partial<PriceData>)[]>([]);
// Stable singleton transport layer — all consumers share one WS connection.
const wsManager = WebSocketManager.getInstance();
// Flush pending updates to state and IndexedDB
// ------------------------------------------------------------------
// flushPendingUpdates — collapses all buffered ticks into one setState.
// Stable identity (empty dep-array) so the RAF interval never restarts.
// ------------------------------------------------------------------
const flushPendingUpdates = useCallback(() => {
if (pendingUpdatesRef.current.length === 0) return;
const updates = [...pendingUpdatesRef.current];
pendingUpdatesRef.current.length = 0;
// Stream into IndexedDB for offline replay / instant rehydration
const priceUpdates = updates.filter(
(u): u is PriceData => 'id' in u && 'assetPair' in u && 'timestamp' in u,
);
if (priceUpdates.length > 0) {
savePriceBatch(priceUpdates).catch(() => {});
}
// Apply all updates in a single state commit
setLastUpdate((prev: PriceData | null) => {
let current = prev;
for (const update of updates) {
current = current
? { ...current, ...(update as PriceData) }
: (update as PriceData);
}
return current;
});
}, []);
const disconnect = useCallback(() => {
if (subscribedAssetsRef.current.size > 0) {
wsManager.unsubscribeFromAssets(Array.from(subscribedAssetsRef.current));
subscribedAssetsRef.current.clear();
}
setIsConnected(false);
}, [wsManager]);
// ------------------------------------------------------------------
// subscribeToAsset / unsubscribeFromAsset — delegate to the manager.
// ------------------------------------------------------------------
const subscribeToAsset = useCallback(
(assetId: string) => {
if (!subscribedAssetsRef.current.has(assetId)) {
subscribedAssetsRef.current.add(assetId);
wsManager.subscribeToAssets([assetId]);
}
},
[wsManager],
);
const unsubscribeFromAsset = useCallback(
(assetId: string) => {
if (subscribedAssetsRef.current.has(assetId)) {
subscribedAssetsRef.current.delete(assetId);
wsManager.unsubscribeFromAssets([assetId]);
}
},
[wsManager],
);
// ------------------------------------------------------------------
// reconnect — tears down and re-connects via the manager.
// ------------------------------------------------------------------
const reconnect = useCallback(() => {
wsManager.connect();
}, [wsManager]);
// ------------------------------------------------------------------
// Mount effect — register listeners and initial asset subscriptions.
// Runs once per visibility transition; all callbacks are scoped to this
// consumer so global WebSocket traffic does not force unrelated trees to
// reconcile.
// ------------------------------------------------------------------
useEffect(() => {
const handleIncomingData = (data: any) => {
if (!isVisible) return;
if (
data &&
typeof data === "object" &&
"type" in data &&
data.type === "payout_status"
) {
const { transactionId, status, error } = data;
if (transactionId && status) {
setPayoutStatus({
transactionId,
status,
error,
});
}
return;
}
pendingUpdatesRef.current.push(data);
};
const handleStatusChange = (status: boolean) => {
setIsConnected(status);
if (!status) {
setError("WebSocket disconnected");
} else {
setError(null);
}
};
wsManager.subscribeToMessages(handleIncomingData);
wsManager.subscribeToStatus(handleStatusChange);
wsManager.addConsumer();
const assetsOnMount = Array.from(subscribedAssetsRef.current);
if (assetsOnMount.length > 0) {
wsManager.subscribeToAssets(assetsOnMount);
}
return () => {
wsManager.unsubscribeFromMessages(handleIncomingData);
wsManager.unsubscribeFromStatus(handleStatusChange);
// Unsubscribe from ALL assets this consumer is tracking — not just the
// snapshot captured at mount — so dynamically added subscriptions are
// cleaned up and do not leak in the singleton manager.
const remaining = Array.from(subscribedAssetsRef.current);
if (remaining.length > 0) {
wsManager.unsubscribeFromAssets(remaining);
subscribedAssetsRef.current.clear();
}
flushPendingUpdates();
wsManager.removeConsumer();
};
}, [wsManager, isVisible, setError, flushPendingUpdates]);
// Master layout clock — flush buffered price ticks at most once per frame
// while the socket is connected, keeping all state writes off the critical
// user-interaction lane.
useRAFInterval(flushPendingUpdates, 350, isConnected);
return {
isConnected,
lastUpdate,
payoutStatus,
error,
reconnectAttempts: 0,
subscribeToAsset,
unsubscribeFromAsset,
disconnect,
reconnect,
};
}
// ---------------------------------------------------------------------------
// Public API — selector-based `useSocket`.
// ---------------------------------------------------------------------------
/**
* Subscribe to WebSocket state with an optional selector to pick only the
* properties your component needs. When the selector is provided, the hook
* returns only the selected value, memoised so child components wrapped in
* `React.memo` are not re-rendered by unrelated state changes.
*
* @example
* // Re-renders only when `isConnected` changes.
* const isConnected = useSocket(options, (s) => s.isConnected)
*
* @example
* // Re-renders on every tick (same as calling without a selector).
* const full = useSocket(options)
*/
export function useSocket<Selected = UseSocketReturn>(
options?: UseSocketOptions,
selector?: (state: UseSocketReturn) => Selected,
): Selected {
const state = useSocketState(options ?? {});
return useMemo(
() => (selector ? selector(state) : (state as unknown as Selected)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
selector,
state.isConnected,
state.lastUpdate,
state.payoutStatus,
state.error,
state.subscribeToAsset,
state.unsubscribeFromAsset,
state.disconnect,
state.reconnect,
],
);
}