@croacroa/react-native-template 2.0.1 → 3.2.0
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/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -0
- package/README.md +446 -399
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -0
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -23
- package/constants/config.ts +135 -97
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -27
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +64 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -0
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -175
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- package/utils/withAccessibility.tsx +272 -272
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview WebSocket connection manager with auto-reconnect
|
|
3
|
+
* Provides a robust WebSocket client with heartbeat, message queuing,
|
|
4
|
+
* channel subscriptions, and exponential backoff reconnection.
|
|
5
|
+
* @module services/realtime/websocket-manager
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ConnectionStatus,
|
|
10
|
+
WebSocketConfig,
|
|
11
|
+
WebSocketMessage,
|
|
12
|
+
MessageHandler,
|
|
13
|
+
StatusHandler,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
/** Maximum reconnect delay cap in milliseconds */
|
|
17
|
+
const MAX_RECONNECT_DELAY = 30_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* WebSocket connection manager.
|
|
21
|
+
*
|
|
22
|
+
* Manages a single WebSocket connection with support for:
|
|
23
|
+
* - Automatic reconnection with exponential backoff
|
|
24
|
+
* - Heartbeat keep-alive messages
|
|
25
|
+
* - Message queuing when disconnected
|
|
26
|
+
* - Channel-based subscriptions
|
|
27
|
+
* - Global and per-channel message handlers
|
|
28
|
+
* - Status change listeners
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { WebSocketManager } from '@/services/realtime/websocket-manager';
|
|
33
|
+
*
|
|
34
|
+
* const manager = new WebSocketManager({
|
|
35
|
+
* url: 'wss://api.example.com/ws',
|
|
36
|
+
* getToken: async () => authStore.getState().token,
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* manager.onStatusChange((status) => console.log('WS status:', status));
|
|
40
|
+
* manager.connect();
|
|
41
|
+
*
|
|
42
|
+
* const unsub = manager.subscribe('chat:room-1', (msg) => {
|
|
43
|
+
* console.log('New message:', msg.payload);
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* manager.send('chat:message', { text: 'Hello!' }, 'chat:room-1');
|
|
47
|
+
*
|
|
48
|
+
* // Later: cleanup
|
|
49
|
+
* unsub();
|
|
50
|
+
* manager.disconnect();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export class WebSocketManager {
|
|
54
|
+
private ws: WebSocket | null = null;
|
|
55
|
+
private config: Required<WebSocketConfig>;
|
|
56
|
+
private status: ConnectionStatus = "disconnected";
|
|
57
|
+
private reconnectAttempts = 0;
|
|
58
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
private connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
61
|
+
private messageQueue: WebSocketMessage[] = [];
|
|
62
|
+
private shouldReconnect = true;
|
|
63
|
+
|
|
64
|
+
/** Per-channel message handlers */
|
|
65
|
+
private messageHandlers: Map<string, Set<MessageHandler>> = new Map();
|
|
66
|
+
/** Global message handlers (receive all messages) */
|
|
67
|
+
private globalHandlers: Set<MessageHandler> = new Set();
|
|
68
|
+
/** Connection status change handlers */
|
|
69
|
+
private statusHandlers: Set<StatusHandler> = new Set();
|
|
70
|
+
|
|
71
|
+
constructor(config: WebSocketConfig) {
|
|
72
|
+
this.config = {
|
|
73
|
+
url: config.url,
|
|
74
|
+
getToken: config.getToken ?? (async () => null),
|
|
75
|
+
autoReconnect: config.autoReconnect ?? true,
|
|
76
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
|
|
77
|
+
reconnectBaseDelay: config.reconnectBaseDelay ?? 1000,
|
|
78
|
+
heartbeatInterval: config.heartbeatInterval ?? 30_000,
|
|
79
|
+
connectionTimeout: config.connectionTimeout ?? 10_000,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Open the WebSocket connection.
|
|
85
|
+
* If a `getToken` function was provided in the config, the token is appended
|
|
86
|
+
* as a `token` query parameter on the connection URL.
|
|
87
|
+
*/
|
|
88
|
+
async connect(): Promise<void> {
|
|
89
|
+
if (this.status === "connecting" || this.status === "connected") {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.shouldReconnect = this.config.autoReconnect;
|
|
94
|
+
this.setStatus("connecting");
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
let url = this.config.url;
|
|
98
|
+
|
|
99
|
+
// Inject auth token as query param if available
|
|
100
|
+
const token = await this.config.getToken();
|
|
101
|
+
if (token) {
|
|
102
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
103
|
+
url = `${url}${separator}token=${encodeURIComponent(token)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.ws = new WebSocket(url);
|
|
107
|
+
|
|
108
|
+
// Set a connection timeout
|
|
109
|
+
this.connectionTimeoutTimer = setTimeout(() => {
|
|
110
|
+
if (this.status === "connecting") {
|
|
111
|
+
console.warn("[WebSocketManager] Connection timeout");
|
|
112
|
+
this.ws?.close();
|
|
113
|
+
this.attemptReconnect();
|
|
114
|
+
}
|
|
115
|
+
}, this.config.connectionTimeout);
|
|
116
|
+
|
|
117
|
+
this.ws.onopen = () => {
|
|
118
|
+
this.clearConnectionTimeout();
|
|
119
|
+
this.reconnectAttempts = 0;
|
|
120
|
+
this.setStatus("connected");
|
|
121
|
+
this.startHeartbeat();
|
|
122
|
+
this.flushQueue();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
this.ws.onmessage = (event: MessageEvent) => {
|
|
126
|
+
this.handleMessage(event);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
this.ws.onclose = () => {
|
|
130
|
+
this.stopHeartbeat();
|
|
131
|
+
this.clearConnectionTimeout();
|
|
132
|
+
if (this.status !== "disconnected") {
|
|
133
|
+
this.setStatus("disconnected");
|
|
134
|
+
this.attemptReconnect();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
this.ws.onerror = (error: Event) => {
|
|
139
|
+
console.error("[WebSocketManager] WebSocket error:", error);
|
|
140
|
+
// onclose will fire after onerror, so reconnect logic is handled there
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error("[WebSocketManager] Failed to connect:", error);
|
|
144
|
+
this.setStatus("disconnected");
|
|
145
|
+
this.attemptReconnect();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Gracefully close the WebSocket connection.
|
|
151
|
+
* Disables auto-reconnect and clears all timers.
|
|
152
|
+
*/
|
|
153
|
+
disconnect(): void {
|
|
154
|
+
this.shouldReconnect = false;
|
|
155
|
+
this.clearReconnectTimer();
|
|
156
|
+
this.stopHeartbeat();
|
|
157
|
+
this.clearConnectionTimeout();
|
|
158
|
+
|
|
159
|
+
if (this.ws) {
|
|
160
|
+
this.ws.onopen = null;
|
|
161
|
+
this.ws.onmessage = null;
|
|
162
|
+
this.ws.onclose = null;
|
|
163
|
+
this.ws.onerror = null;
|
|
164
|
+
this.ws.close();
|
|
165
|
+
this.ws = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.setStatus("disconnected");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Send a typed message over the WebSocket.
|
|
173
|
+
* If the connection is not open, the message is queued and sent
|
|
174
|
+
* once the connection is (re)established.
|
|
175
|
+
*
|
|
176
|
+
* @typeParam T - The shape of the message payload
|
|
177
|
+
* @param type - Message type identifier
|
|
178
|
+
* @param payload - The message payload
|
|
179
|
+
* @param channel - Optional channel to target
|
|
180
|
+
*/
|
|
181
|
+
send<T = unknown>(type: string, payload: T, channel?: string): void {
|
|
182
|
+
const message: WebSocketMessage<T> = {
|
|
183
|
+
type,
|
|
184
|
+
channel,
|
|
185
|
+
payload,
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
190
|
+
this.ws.send(JSON.stringify(message));
|
|
191
|
+
} else {
|
|
192
|
+
this.messageQueue.push(message as WebSocketMessage);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Subscribe to messages on a specific channel.
|
|
198
|
+
* Sends a `subscribe` message to the server and registers a local handler.
|
|
199
|
+
*
|
|
200
|
+
* @param channel - The channel name to subscribe to
|
|
201
|
+
* @param handler - Callback invoked for each message on this channel
|
|
202
|
+
* @returns Unsubscribe function that removes the handler and sends an `unsubscribe` message
|
|
203
|
+
*/
|
|
204
|
+
subscribe<T = unknown>(
|
|
205
|
+
channel: string,
|
|
206
|
+
handler: MessageHandler<T>
|
|
207
|
+
): () => void {
|
|
208
|
+
if (!this.messageHandlers.has(channel)) {
|
|
209
|
+
this.messageHandlers.set(channel, new Set());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const handlers = this.messageHandlers.get(channel)!;
|
|
213
|
+
handlers.add(handler as MessageHandler);
|
|
214
|
+
|
|
215
|
+
// Notify the server about the subscription
|
|
216
|
+
this.send("subscribe", { channel });
|
|
217
|
+
|
|
218
|
+
return () => {
|
|
219
|
+
handlers.delete(handler as MessageHandler);
|
|
220
|
+
if (handlers.size === 0) {
|
|
221
|
+
this.messageHandlers.delete(channel);
|
|
222
|
+
}
|
|
223
|
+
// Notify the server about the unsubscription
|
|
224
|
+
this.send("unsubscribe", { channel });
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Register a global message handler that receives all messages
|
|
230
|
+
* regardless of channel.
|
|
231
|
+
*
|
|
232
|
+
* @param handler - Callback invoked for every incoming message
|
|
233
|
+
* @returns Unsubscribe function that removes the handler
|
|
234
|
+
*/
|
|
235
|
+
onMessage<T = unknown>(handler: MessageHandler<T>): () => void {
|
|
236
|
+
this.globalHandlers.add(handler as MessageHandler);
|
|
237
|
+
return () => {
|
|
238
|
+
this.globalHandlers.delete(handler as MessageHandler);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Register a handler for connection status changes.
|
|
244
|
+
*
|
|
245
|
+
* @param handler - Callback invoked with the new status
|
|
246
|
+
* @returns Unsubscribe function that removes the handler
|
|
247
|
+
*/
|
|
248
|
+
onStatusChange(handler: StatusHandler): () => void {
|
|
249
|
+
this.statusHandlers.add(handler);
|
|
250
|
+
return () => {
|
|
251
|
+
this.statusHandlers.delete(handler);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get the current connection status.
|
|
257
|
+
*
|
|
258
|
+
* @returns The current ConnectionStatus
|
|
259
|
+
*/
|
|
260
|
+
getStatus(): ConnectionStatus {
|
|
261
|
+
return this.status;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// =========================================
|
|
265
|
+
// Private methods
|
|
266
|
+
// =========================================
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Update the connection status and notify all status handlers.
|
|
270
|
+
*/
|
|
271
|
+
private setStatus(newStatus: ConnectionStatus): void {
|
|
272
|
+
if (this.status === newStatus) return;
|
|
273
|
+
this.status = newStatus;
|
|
274
|
+
this.statusHandlers.forEach((handler) => {
|
|
275
|
+
try {
|
|
276
|
+
handler(newStatus);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error("[WebSocketManager] Status handler error:", error);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Validate that parsed data conforms to the WebSocketMessage shape.
|
|
285
|
+
*/
|
|
286
|
+
private isValidMessage(data: unknown): data is WebSocketMessage {
|
|
287
|
+
return (
|
|
288
|
+
typeof data === "object" &&
|
|
289
|
+
data !== null &&
|
|
290
|
+
typeof (data as Record<string, unknown>).type === "string" &&
|
|
291
|
+
"payload" in data
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parse and dispatch an incoming WebSocket message to the appropriate handlers.
|
|
297
|
+
*/
|
|
298
|
+
private handleMessage(event: MessageEvent): void {
|
|
299
|
+
try {
|
|
300
|
+
const parsed: unknown = JSON.parse(event.data as string);
|
|
301
|
+
|
|
302
|
+
if (!this.isValidMessage(parsed)) {
|
|
303
|
+
console.warn("[WebSocketManager] Skipping invalid message:", parsed);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const message: WebSocketMessage = parsed;
|
|
308
|
+
|
|
309
|
+
// Ignore heartbeat acknowledgements
|
|
310
|
+
if (message.type === "pong") {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Dispatch to global handlers
|
|
315
|
+
this.globalHandlers.forEach((handler) => {
|
|
316
|
+
try {
|
|
317
|
+
handler(message);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error(
|
|
320
|
+
"[WebSocketManager] Global message handler error:",
|
|
321
|
+
error
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Dispatch to channel-specific handlers
|
|
327
|
+
if (message.channel) {
|
|
328
|
+
const channelHandlers = this.messageHandlers.get(message.channel);
|
|
329
|
+
if (channelHandlers) {
|
|
330
|
+
channelHandlers.forEach((handler) => {
|
|
331
|
+
try {
|
|
332
|
+
handler(message);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error(
|
|
335
|
+
"[WebSocketManager] Channel message handler error:",
|
|
336
|
+
error
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error("[WebSocketManager] Failed to parse message:", error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Attempt to reconnect using exponential backoff.
|
|
349
|
+
* The delay doubles with each attempt, capped at MAX_RECONNECT_DELAY.
|
|
350
|
+
*/
|
|
351
|
+
private attemptReconnect(): void {
|
|
352
|
+
if (!this.shouldReconnect) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
357
|
+
console.warn(
|
|
358
|
+
`[WebSocketManager] Max reconnect attempts (${this.config.maxReconnectAttempts}) reached`
|
|
359
|
+
);
|
|
360
|
+
this.setStatus("disconnected");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.setStatus("reconnecting");
|
|
365
|
+
|
|
366
|
+
const delay = Math.min(
|
|
367
|
+
this.config.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts),
|
|
368
|
+
MAX_RECONNECT_DELAY
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
console.log(
|
|
372
|
+
`[WebSocketManager] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.config.maxReconnectAttempts})`
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
this.reconnectTimer = setTimeout(() => {
|
|
376
|
+
this.reconnectAttempts++;
|
|
377
|
+
this.connect();
|
|
378
|
+
}, delay);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Start sending periodic heartbeat (ping) messages to keep the connection alive.
|
|
383
|
+
*/
|
|
384
|
+
private startHeartbeat(): void {
|
|
385
|
+
this.stopHeartbeat();
|
|
386
|
+
this.heartbeatTimer = setInterval(() => {
|
|
387
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
388
|
+
this.send("ping", {});
|
|
389
|
+
}
|
|
390
|
+
}, this.config.heartbeatInterval);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Stop the heartbeat timer.
|
|
395
|
+
*/
|
|
396
|
+
private stopHeartbeat(): void {
|
|
397
|
+
if (this.heartbeatTimer) {
|
|
398
|
+
clearInterval(this.heartbeatTimer);
|
|
399
|
+
this.heartbeatTimer = null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Send all queued messages that were buffered while disconnected.
|
|
405
|
+
*/
|
|
406
|
+
private flushQueue(): void {
|
|
407
|
+
if (this.messageQueue.length === 0) return;
|
|
408
|
+
|
|
409
|
+
const queue = [...this.messageQueue];
|
|
410
|
+
this.messageQueue = [];
|
|
411
|
+
|
|
412
|
+
queue.forEach((message) => {
|
|
413
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
414
|
+
this.ws.send(JSON.stringify(message));
|
|
415
|
+
} else {
|
|
416
|
+
// Re-queue if connection was lost during flush
|
|
417
|
+
this.messageQueue.push(message);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Clear the reconnect timer.
|
|
424
|
+
*/
|
|
425
|
+
private clearReconnectTimer(): void {
|
|
426
|
+
if (this.reconnectTimer) {
|
|
427
|
+
clearTimeout(this.reconnectTimer);
|
|
428
|
+
this.reconnectTimer = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Clear the connection timeout timer.
|
|
434
|
+
*/
|
|
435
|
+
private clearConnectionTimeout(): void {
|
|
436
|
+
if (this.connectionTimeoutTimer) {
|
|
437
|
+
clearTimeout(this.connectionTimeoutTimer);
|
|
438
|
+
this.connectionTimeoutTimer = null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|