react-ws-kit 1.0.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 react-websocket-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # react-ws-kit
2
+
3
+ A production-quality, typed WebSocket hook for React with intelligent connection sharing, message queuing, and comprehensive reconnection handling.
4
+
5
+ ## Features
6
+
7
+ - **TypeScript First**: Full generic type support for send/receive messages
8
+ - **Connection Sharing**: Automatically shares WebSocket instances across components with matching configurations
9
+ - **Per-Hook State**: Each hook maintains its own `allData` history and UI state
10
+ - **Message Queuing**: Optional FIFO queue for offline message buffering
11
+ - **Auto-Reconnect**: Configurable linear backoff strategy
12
+ - **Kill Switch**: Programmatically close connections for all subscribers
13
+ - **Zero Dependencies**: Only peer dependency is React 18+
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install react-ws-kit
19
+ ```
20
+
21
+ ## Basic Usage
22
+
23
+ ```typescript
24
+ import { useSocket } from 'react-ws-kit'
25
+
26
+ function ChatComponent() {
27
+ const { connect, disconnect, send, status, lastReturnedData, allData } =
28
+ useSocket('ws://localhost:3001/chat')
29
+
30
+ return (
31
+ <div>
32
+ <button onClick={connect}>Connect</button>
33
+ <button onClick={disconnect}>Disconnect</button>
34
+ <button onClick={() => send({ message: 'Hello!' })}>Send</button>
35
+ <p>Status: {status}</p>
36
+ </div>
37
+ )
38
+ }
39
+ ```
40
+
41
+ ## Typed Usage
42
+
43
+ ```typescript
44
+ type MessageIn = {
45
+ type: 'chat'
46
+ user: string
47
+ message: string
48
+ timestamp: number
49
+ }
50
+
51
+ type MessageOut = {
52
+ message: string
53
+ }
54
+
55
+ function TypedChat() {
56
+ const { send, lastReturnedData, allData } = useSocket<MessageIn, MessageOut>(
57
+ 'ws://localhost:3001/chat',
58
+ {
59
+ autoConnect: true,
60
+ autoReconnect: true,
61
+ reconnectAttempts: 5,
62
+ reconnectDelay: 1000,
63
+ queueMessages: true,
64
+ maxQueueSize: 100
65
+ }
66
+ )
67
+
68
+ // lastReturnedData is typed as MessageIn | undefined
69
+ // send accepts MessageOut
70
+
71
+ return <div>{lastReturnedData?.message}</div>
72
+ }
73
+ ```
74
+
75
+ ## API
76
+
77
+ ### `useSocket<TIn, TOut>(url, options?)`
78
+
79
+ #### Parameters
80
+
81
+ - `url: string` - WebSocket URL
82
+ - `options?: Options<TIn, TOut>` - Configuration object
83
+
84
+ #### Options
85
+
86
+ | Option | Type | Default | Description |
87
+ |--------|------|---------|-------------|
88
+ | `autoConnect` | `boolean` | `false` | Connect automatically on mount |
89
+ | `protocols` | `string \| string[]` | `undefined` | WebSocket sub-protocols |
90
+ | `autoReconnect` | `boolean` | `false` | Enable automatic reconnection |
91
+ | `reconnectAttempts` | `number` | `Infinity` | Max reconnection attempts |
92
+ | `reconnectDelay` | `number` | `1000` | Base delay in ms (linear backoff) |
93
+ | `queueMessages` | `boolean` | `false` | Queue messages when disconnected |
94
+ | `maxQueueSize` | `number` | `50` | Maximum queue size |
95
+ | `parse` | `(event: MessageEvent) => TIn` | `JSON.parse` | Custom message parser |
96
+ | `serialize` | `(data: TOut) => string` | `JSON.stringify` | Custom message serializer |
97
+ | `key` | `string` | `undefined` | Deterministic key for function identity |
98
+
99
+ #### Return Value
100
+
101
+ ```typescript
102
+ {
103
+ connect: () => void
104
+ disconnect: () => void
105
+ send: (data: TOut) => void
106
+ status: "disconnected" | "connecting" | "connected" | "error" | "reconnecting"
107
+ lastReturnedData?: TIn
108
+ allData: TIn[]
109
+ killSocketForAllSubscribers: () => void
110
+ }
111
+ ```
112
+
113
+ ## Connection Sharing
114
+
115
+ Hooks automatically share a WebSocket if all of the following match:
116
+
117
+ - URL
118
+ - Protocols
119
+ - Auto-reconnect settings
120
+ - Queue settings
121
+ - Parse/serialize functions (by reference or via `key` option)
122
+
123
+ ```typescript
124
+ // These two hooks share the same WebSocket
125
+ function ComponentA() {
126
+ const ws = useSocket('ws://localhost:3001/chat', { queueMessages: true })
127
+ // ...
128
+ }
129
+
130
+ function ComponentB() {
131
+ const ws = useSocket('ws://localhost:3001/chat', { queueMessages: true })
132
+ // ...
133
+ }
134
+ ```
135
+
136
+ ## Reconnection Strategy
137
+
138
+ Linear backoff: `delay = reconnectDelay * attemptNumber`
139
+
140
+ ```typescript
141
+ useSocket(url, {
142
+ autoReconnect: true,
143
+ reconnectAttempts: 5,
144
+ reconnectDelay: 1000
145
+ })
146
+
147
+ // Attempt 1: 1000ms delay
148
+ // Attempt 2: 2000ms delay
149
+ // Attempt 3: 3000ms delay
150
+ // ...
151
+ ```
152
+
153
+ ## Kill Switch
154
+
155
+ The kill switch closes the socket for **all** subscribers and prevents auto-reconnect:
156
+
157
+ ```typescript
158
+ const { killSocketForAllSubscribers } = useSocket(url, { autoReconnect: true })
159
+
160
+ // Disconnects all components using this socket
161
+ // Auto-reconnect is disabled until manual connect()
162
+ killSocketForAllSubscribers()
163
+ ```
164
+
165
+ ## Message Queuing
166
+
167
+ When `queueMessages: true`, messages sent while disconnected are queued and flushed on reconnection:
168
+
169
+ ```typescript
170
+ const { send } = useSocket(url, {
171
+ queueMessages: true,
172
+ maxQueueSize: 100
173
+ })
174
+
175
+ // Even if disconnected, messages are queued
176
+ send({ message: 'Hello' })
177
+ send({ message: 'World' })
178
+
179
+ // On reconnect, both messages are sent in order
180
+ ```
181
+
182
+ ## Testing
183
+
184
+ The package includes comprehensive unit tests:
185
+
186
+ ```bash
187
+ npm test
188
+ ```
189
+
190
+ ## License
191
+
192
+ MIT
193
+
@@ -0,0 +1,119 @@
1
+ /**
2
+ * WebSocket connection status
3
+ */
4
+ type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting";
5
+ /**
6
+ * Options for configuring the WebSocket hook
7
+ */
8
+ interface Options<TIn = unknown, TOut = unknown> {
9
+ /**
10
+ * Automatically connect when the hook mounts
11
+ * @default false
12
+ */
13
+ autoConnect?: boolean;
14
+ /**
15
+ * WebSocket sub-protocols
16
+ */
17
+ protocols?: string | string[];
18
+ /**
19
+ * Automatically reconnect on disconnect
20
+ * @default false
21
+ */
22
+ autoReconnect?: boolean;
23
+ /**
24
+ * Maximum number of reconnection attempts (Infinity = unlimited)
25
+ * @default Infinity
26
+ */
27
+ reconnectAttempts?: number;
28
+ /**
29
+ * Base delay in milliseconds for reconnection
30
+ * Actual delay = reconnectDelay * attemptNumber (linear backoff)
31
+ * @default 1000
32
+ */
33
+ reconnectDelay?: number;
34
+ /**
35
+ * Queue outgoing messages when disconnected
36
+ * @default false
37
+ */
38
+ queueMessages?: boolean;
39
+ /**
40
+ * Maximum size of the message queue
41
+ * @default 50
42
+ */
43
+ maxQueueSize?: number;
44
+ /**
45
+ * Custom parser for incoming messages
46
+ * @default (event) => JSON.parse(event.data)
47
+ */
48
+ parse?: (event: MessageEvent) => TIn;
49
+ /**
50
+ * Custom serializer for outgoing messages
51
+ * @default (data) => JSON.stringify(data)
52
+ */
53
+ serialize?: (data: TOut) => string;
54
+ /**
55
+ * Optional deterministic key for function identity
56
+ * Use when parse/serialize functions are not referentially stable
57
+ */
58
+ key?: string;
59
+ }
60
+ /**
61
+ * Return value of the useSocket hook
62
+ */
63
+ interface UseSocketReturn<TIn = unknown, TOut = unknown> {
64
+ /**
65
+ * Manually connect to the WebSocket
66
+ */
67
+ connect: () => void;
68
+ /**
69
+ * Manually disconnect from the WebSocket
70
+ */
71
+ disconnect: () => void;
72
+ /**
73
+ * Send a typed message through the WebSocket
74
+ */
75
+ send: (data: TOut) => void;
76
+ /**
77
+ * Current connection status
78
+ */
79
+ status: Status;
80
+ /**
81
+ * Most recently received message
82
+ */
83
+ lastReturnedData?: TIn;
84
+ /**
85
+ * All messages received by this hook instance
86
+ */
87
+ allData: TIn[];
88
+ /**
89
+ * Close the socket for all subscribers and disable auto-reconnect
90
+ */
91
+ killSocketForAllSubscribers: () => void;
92
+ }
93
+ /**
94
+ * Read-only socket information for debugging/dashboard
95
+ */
96
+ interface SocketInfo {
97
+ key: string;
98
+ url: string;
99
+ status: Status;
100
+ refCount: number;
101
+ queueLength: number;
102
+ reconnectAttemptsMade: number;
103
+ killed: boolean;
104
+ }
105
+
106
+ /**
107
+ * Typed WebSocket hook with sharing, queuing, and kill switch
108
+ */
109
+ declare function useSocket<TIn = unknown, TOut = unknown>(url: string, options?: Options<TIn, TOut>): UseSocketReturn<TIn, TOut>;
110
+
111
+ /**
112
+ * Export for debugging/dashboard use
113
+ */
114
+ declare function getSocketStore(): {
115
+ getAllSocketInfo: () => SocketInfo[];
116
+ clearQueue: (key: string) => void;
117
+ };
118
+
119
+ export { type Options, type SocketInfo, type Status, type UseSocketReturn, getSocketStore, useSocket };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * WebSocket connection status
3
+ */
4
+ type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting";
5
+ /**
6
+ * Options for configuring the WebSocket hook
7
+ */
8
+ interface Options<TIn = unknown, TOut = unknown> {
9
+ /**
10
+ * Automatically connect when the hook mounts
11
+ * @default false
12
+ */
13
+ autoConnect?: boolean;
14
+ /**
15
+ * WebSocket sub-protocols
16
+ */
17
+ protocols?: string | string[];
18
+ /**
19
+ * Automatically reconnect on disconnect
20
+ * @default false
21
+ */
22
+ autoReconnect?: boolean;
23
+ /**
24
+ * Maximum number of reconnection attempts (Infinity = unlimited)
25
+ * @default Infinity
26
+ */
27
+ reconnectAttempts?: number;
28
+ /**
29
+ * Base delay in milliseconds for reconnection
30
+ * Actual delay = reconnectDelay * attemptNumber (linear backoff)
31
+ * @default 1000
32
+ */
33
+ reconnectDelay?: number;
34
+ /**
35
+ * Queue outgoing messages when disconnected
36
+ * @default false
37
+ */
38
+ queueMessages?: boolean;
39
+ /**
40
+ * Maximum size of the message queue
41
+ * @default 50
42
+ */
43
+ maxQueueSize?: number;
44
+ /**
45
+ * Custom parser for incoming messages
46
+ * @default (event) => JSON.parse(event.data)
47
+ */
48
+ parse?: (event: MessageEvent) => TIn;
49
+ /**
50
+ * Custom serializer for outgoing messages
51
+ * @default (data) => JSON.stringify(data)
52
+ */
53
+ serialize?: (data: TOut) => string;
54
+ /**
55
+ * Optional deterministic key for function identity
56
+ * Use when parse/serialize functions are not referentially stable
57
+ */
58
+ key?: string;
59
+ }
60
+ /**
61
+ * Return value of the useSocket hook
62
+ */
63
+ interface UseSocketReturn<TIn = unknown, TOut = unknown> {
64
+ /**
65
+ * Manually connect to the WebSocket
66
+ */
67
+ connect: () => void;
68
+ /**
69
+ * Manually disconnect from the WebSocket
70
+ */
71
+ disconnect: () => void;
72
+ /**
73
+ * Send a typed message through the WebSocket
74
+ */
75
+ send: (data: TOut) => void;
76
+ /**
77
+ * Current connection status
78
+ */
79
+ status: Status;
80
+ /**
81
+ * Most recently received message
82
+ */
83
+ lastReturnedData?: TIn;
84
+ /**
85
+ * All messages received by this hook instance
86
+ */
87
+ allData: TIn[];
88
+ /**
89
+ * Close the socket for all subscribers and disable auto-reconnect
90
+ */
91
+ killSocketForAllSubscribers: () => void;
92
+ }
93
+ /**
94
+ * Read-only socket information for debugging/dashboard
95
+ */
96
+ interface SocketInfo {
97
+ key: string;
98
+ url: string;
99
+ status: Status;
100
+ refCount: number;
101
+ queueLength: number;
102
+ reconnectAttemptsMade: number;
103
+ killed: boolean;
104
+ }
105
+
106
+ /**
107
+ * Typed WebSocket hook with sharing, queuing, and kill switch
108
+ */
109
+ declare function useSocket<TIn = unknown, TOut = unknown>(url: string, options?: Options<TIn, TOut>): UseSocketReturn<TIn, TOut>;
110
+
111
+ /**
112
+ * Export for debugging/dashboard use
113
+ */
114
+ declare function getSocketStore(): {
115
+ getAllSocketInfo: () => SocketInfo[];
116
+ clearQueue: (key: string) => void;
117
+ };
118
+
119
+ export { type Options, type SocketInfo, type Status, type UseSocketReturn, getSocketStore, useSocket };
package/dist/index.js ADDED
@@ -0,0 +1,394 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ getSocketStore: () => getSocketStore,
24
+ useSocket: () => useSocket
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/useSocket.ts
29
+ var import_react = require("react");
30
+
31
+ // src/hash.ts
32
+ function simpleHash(str) {
33
+ let hash = 0;
34
+ for (let i = 0; i < str.length; i++) {
35
+ const char = str.charCodeAt(i);
36
+ hash = (hash << 5) - hash + char;
37
+ hash = hash & hash;
38
+ }
39
+ return hash.toString(36);
40
+ }
41
+ function isDefaultParse(fn) {
42
+ const fnStr = fn.toString();
43
+ return fnStr.includes("JSON.parse") && fnStr.includes("event.data");
44
+ }
45
+ function isDefaultSerialize(fn) {
46
+ const fnStr = fn.toString();
47
+ return fnStr.includes("JSON.stringify") && !fnStr.includes("event");
48
+ }
49
+ function createSocketKey(url, config) {
50
+ const parts = [url];
51
+ if (config.protocols) {
52
+ const protocols = Array.isArray(config.protocols) ? config.protocols.sort().join(",") : config.protocols;
53
+ parts.push(`proto:${protocols}`);
54
+ }
55
+ parts.push(`ar:${config.autoReconnect}`);
56
+ parts.push(`ra:${config.reconnectAttempts}`);
57
+ parts.push(`rd:${config.reconnectDelay}`);
58
+ parts.push(`qm:${config.queueMessages}`);
59
+ parts.push(`mqs:${config.maxQueueSize}`);
60
+ if (config.key) {
61
+ parts.push(`key:${config.key}`);
62
+ } else {
63
+ const parseHash = isDefaultParse(config.parse) ? "default-parse" : simpleHash(config.parse.toString());
64
+ const serializeHash = isDefaultSerialize(config.serialize) ? "default-serialize" : simpleHash(config.serialize.toString());
65
+ parts.push(`ph:${parseHash}`);
66
+ parts.push(`sh:${serializeHash}`);
67
+ }
68
+ return parts.join("|");
69
+ }
70
+
71
+ // src/store.ts
72
+ var SocketStore = class {
73
+ constructor() {
74
+ this.sockets = /* @__PURE__ */ new Map();
75
+ }
76
+ /**
77
+ * Get a socket instance by key
78
+ */
79
+ get(key) {
80
+ return this.sockets.get(key);
81
+ }
82
+ /**
83
+ * Set a socket instance
84
+ */
85
+ set(key, instance) {
86
+ this.sockets.set(key, instance);
87
+ }
88
+ /**
89
+ * Delete a socket instance
90
+ */
91
+ delete(key) {
92
+ this.sockets.delete(key);
93
+ }
94
+ /**
95
+ * Check if a socket key exists
96
+ */
97
+ has(key) {
98
+ return this.sockets.has(key);
99
+ }
100
+ /**
101
+ * Get all socket keys
102
+ */
103
+ keys() {
104
+ return Array.from(this.sockets.keys());
105
+ }
106
+ /**
107
+ * Get read-only information about all sockets (for debugging/dashboard)
108
+ */
109
+ getAllSocketInfo() {
110
+ return Array.from(this.sockets.entries()).map(([key, instance]) => ({
111
+ key,
112
+ url: instance.socket?.url || "N/A",
113
+ status: instance.status,
114
+ refCount: instance.refCount,
115
+ queueLength: instance.messageQueue.length,
116
+ reconnectAttemptsMade: instance.reconnectAttemptsMade,
117
+ killed: instance.killed
118
+ }));
119
+ }
120
+ /**
121
+ * Clear a socket's message queue (for debugging/testing)
122
+ */
123
+ clearQueue(key) {
124
+ const instance = this.sockets.get(key);
125
+ if (instance) {
126
+ instance.messageQueue = [];
127
+ }
128
+ }
129
+ };
130
+ var socketStore = new SocketStore();
131
+ function getSocketStore() {
132
+ return {
133
+ getAllSocketInfo: () => socketStore.getAllSocketInfo(),
134
+ clearQueue: (key) => socketStore.clearQueue(key)
135
+ };
136
+ }
137
+
138
+ // src/useSocket.ts
139
+ var defaultParse = (event) => {
140
+ return JSON.parse(event.data);
141
+ };
142
+ var defaultSerialize = (data) => {
143
+ return JSON.stringify(data);
144
+ };
145
+ function normalizeOptions(options) {
146
+ return {
147
+ autoConnect: options?.autoConnect ?? false,
148
+ protocols: options?.protocols,
149
+ autoReconnect: options?.autoReconnect ?? false,
150
+ reconnectAttempts: options?.reconnectAttempts ?? Infinity,
151
+ reconnectDelay: options?.reconnectDelay ?? 1e3,
152
+ queueMessages: options?.queueMessages ?? false,
153
+ maxQueueSize: options?.maxQueueSize ?? 50,
154
+ parse: options?.parse ?? defaultParse,
155
+ serialize: options?.serialize ?? defaultSerialize,
156
+ key: options?.key
157
+ };
158
+ }
159
+ var subscriberIdCounter = 0;
160
+ function generateSubscriberId() {
161
+ return `sub-${++subscriberIdCounter}-${Date.now()}`;
162
+ }
163
+ function useSocket(url, options) {
164
+ const config = (0, import_react.useRef)(normalizeOptions(options)).current;
165
+ const socketKey = (0, import_react.useRef)(createSocketKey(url, config)).current;
166
+ const subscriberId = (0, import_react.useRef)(generateSubscriberId()).current;
167
+ const [status, setStatus] = (0, import_react.useState)("disconnected");
168
+ const [lastReturnedData, setLastReturnedData] = (0, import_react.useState)(void 0);
169
+ const [allData, setAllData] = (0, import_react.useState)([]);
170
+ const isConnectedRef = (0, import_react.useRef)(false);
171
+ const addToAllData = (0, import_react.useCallback)((data) => {
172
+ setAllData((prev) => [...prev, data]);
173
+ }, []);
174
+ const getOrCreateInstance = (0, import_react.useCallback)(() => {
175
+ let instance = socketStore.get(socketKey);
176
+ if (!instance) {
177
+ instance = {
178
+ socket: null,
179
+ key: socketKey,
180
+ status: "disconnected",
181
+ subscribers: /* @__PURE__ */ new Set(),
182
+ refCount: 0,
183
+ reconnectAttemptsMade: 0,
184
+ messageQueue: [],
185
+ config,
186
+ killed: false,
187
+ reconnectTimer: null
188
+ };
189
+ socketStore.set(socketKey, instance);
190
+ }
191
+ return instance;
192
+ }, [socketKey, config]);
193
+ const updateAllSubscribers = (0, import_react.useCallback)((instance, newStatus) => {
194
+ instance.status = newStatus;
195
+ instance.subscribers.forEach((sub) => {
196
+ sub.setStatus(newStatus);
197
+ });
198
+ }, []);
199
+ const flushQueue = (0, import_react.useCallback)((instance) => {
200
+ if (!instance.socket || instance.socket.readyState !== WebSocket.OPEN) {
201
+ return;
202
+ }
203
+ while (instance.messageQueue.length > 0) {
204
+ const data = instance.messageQueue.shift();
205
+ try {
206
+ const serialized = instance.config.serialize(data);
207
+ instance.socket.send(serialized);
208
+ } catch (error) {
209
+ console.error("[useSocket] Error flushing queued message:", error);
210
+ }
211
+ }
212
+ }, []);
213
+ const scheduleReconnect = (0, import_react.useCallback)((instance) => {
214
+ if (instance.reconnectTimer) {
215
+ clearTimeout(instance.reconnectTimer);
216
+ instance.reconnectTimer = null;
217
+ }
218
+ if (instance.killed || !instance.config.autoReconnect) {
219
+ return;
220
+ }
221
+ if (instance.reconnectAttemptsMade >= instance.config.reconnectAttempts) {
222
+ console.log("[useSocket] Reconnect attempts exhausted");
223
+ updateAllSubscribers(instance, "error");
224
+ return;
225
+ }
226
+ instance.reconnectAttemptsMade++;
227
+ const delay = instance.config.reconnectDelay * instance.reconnectAttemptsMade;
228
+ console.log(`[useSocket] Scheduling reconnect attempt ${instance.reconnectAttemptsMade} in ${delay}ms`);
229
+ updateAllSubscribers(instance, "reconnecting");
230
+ instance.reconnectTimer = setTimeout(() => {
231
+ instance.reconnectTimer = null;
232
+ connectSocket(instance);
233
+ }, delay);
234
+ }, [updateAllSubscribers]);
235
+ const connectSocket = (0, import_react.useCallback)((instance) => {
236
+ if (instance.socket?.readyState === WebSocket.OPEN) {
237
+ updateAllSubscribers(instance, "connected");
238
+ return;
239
+ }
240
+ if (instance.socket?.readyState === WebSocket.CONNECTING) {
241
+ updateAllSubscribers(instance, "connecting");
242
+ return;
243
+ }
244
+ if (instance.reconnectTimer) {
245
+ clearTimeout(instance.reconnectTimer);
246
+ instance.reconnectTimer = null;
247
+ }
248
+ try {
249
+ updateAllSubscribers(instance, "connecting");
250
+ const ws = new WebSocket(url, instance.config.protocols);
251
+ instance.socket = ws;
252
+ ws.onopen = () => {
253
+ console.log("[useSocket] Connected:", socketKey);
254
+ instance.reconnectAttemptsMade = 0;
255
+ updateAllSubscribers(instance, "connected");
256
+ flushQueue(instance);
257
+ };
258
+ ws.onmessage = (event) => {
259
+ try {
260
+ const data = instance.config.parse(event);
261
+ instance.subscribers.forEach((sub) => {
262
+ if (sub.isConnected) {
263
+ sub.setLastReturnedData(data);
264
+ sub.addToAllData(data);
265
+ }
266
+ });
267
+ } catch (error) {
268
+ console.error("[useSocket] Parse error:", error);
269
+ }
270
+ };
271
+ ws.onerror = (error) => {
272
+ console.error("[useSocket] WebSocket error:", error);
273
+ updateAllSubscribers(instance, "error");
274
+ };
275
+ ws.onclose = () => {
276
+ console.log("[useSocket] Disconnected:", socketKey);
277
+ instance.socket = null;
278
+ if (!instance.killed && instance.config.autoReconnect && instance.refCount > 0) {
279
+ scheduleReconnect(instance);
280
+ } else {
281
+ updateAllSubscribers(instance, "disconnected");
282
+ }
283
+ };
284
+ } catch (error) {
285
+ console.error("[useSocket] Connection error:", error);
286
+ updateAllSubscribers(instance, "error");
287
+ if (!instance.killed && instance.config.autoReconnect) {
288
+ scheduleReconnect(instance);
289
+ }
290
+ }
291
+ }, [url, socketKey, updateAllSubscribers, flushQueue, scheduleReconnect]);
292
+ const connect = (0, import_react.useCallback)(() => {
293
+ const instance = getOrCreateInstance();
294
+ if (!isConnectedRef.current) {
295
+ const subscriber = {
296
+ id: subscriberId,
297
+ isConnected: true,
298
+ setStatus,
299
+ setLastReturnedData,
300
+ addToAllData
301
+ };
302
+ instance.subscribers.add(subscriber);
303
+ instance.refCount++;
304
+ isConnectedRef.current = true;
305
+ setStatus(instance.status);
306
+ }
307
+ instance.killed = false;
308
+ connectSocket(instance);
309
+ }, [getOrCreateInstance, subscriberId, addToAllData, connectSocket]);
310
+ const disconnect = (0, import_react.useCallback)(() => {
311
+ const instance = socketStore.get(socketKey);
312
+ if (!instance || !isConnectedRef.current) return;
313
+ const subscriberToRemove = Array.from(instance.subscribers).find(
314
+ (sub) => sub.id === subscriberId
315
+ );
316
+ if (subscriberToRemove) {
317
+ instance.subscribers.delete(subscriberToRemove);
318
+ instance.refCount--;
319
+ isConnectedRef.current = false;
320
+ }
321
+ if (instance.refCount <= 0) {
322
+ if (instance.reconnectTimer) {
323
+ clearTimeout(instance.reconnectTimer);
324
+ instance.reconnectTimer = null;
325
+ }
326
+ if (instance.socket) {
327
+ instance.socket.close();
328
+ instance.socket = null;
329
+ }
330
+ socketStore.delete(socketKey);
331
+ }
332
+ setStatus("disconnected");
333
+ }, [socketKey, subscriberId]);
334
+ const send = (0, import_react.useCallback)((data) => {
335
+ const instance = socketStore.get(socketKey);
336
+ if (!instance) {
337
+ console.error("[useSocket] Cannot send: no instance found");
338
+ return;
339
+ }
340
+ if (instance.socket?.readyState === WebSocket.OPEN) {
341
+ try {
342
+ const serialized = instance.config.serialize(data);
343
+ instance.socket.send(serialized);
344
+ } catch (error) {
345
+ console.error("[useSocket] Send error:", error);
346
+ }
347
+ } else if (instance.config.queueMessages) {
348
+ instance.messageQueue.push(data);
349
+ while (instance.messageQueue.length > instance.config.maxQueueSize) {
350
+ instance.messageQueue.shift();
351
+ }
352
+ console.log(`[useSocket] Message queued (${instance.messageQueue.length}/${instance.config.maxQueueSize})`);
353
+ } else {
354
+ console.error("[useSocket] Cannot send: not connected and queueMessages is false");
355
+ }
356
+ }, [socketKey]);
357
+ const killSocketForAllSubscribers = (0, import_react.useCallback)(() => {
358
+ const instance = socketStore.get(socketKey);
359
+ if (!instance) return;
360
+ console.log("[useSocket] Kill switch activated for:", socketKey);
361
+ instance.killed = true;
362
+ if (instance.reconnectTimer) {
363
+ clearTimeout(instance.reconnectTimer);
364
+ instance.reconnectTimer = null;
365
+ }
366
+ if (instance.socket) {
367
+ instance.socket.close();
368
+ instance.socket = null;
369
+ }
370
+ updateAllSubscribers(instance, "disconnected");
371
+ }, [socketKey, updateAllSubscribers]);
372
+ (0, import_react.useEffect)(() => {
373
+ if (config.autoConnect) {
374
+ connect();
375
+ }
376
+ return () => {
377
+ disconnect();
378
+ };
379
+ }, []);
380
+ return {
381
+ connect,
382
+ disconnect,
383
+ send,
384
+ status,
385
+ lastReturnedData,
386
+ allData,
387
+ killSocketForAllSubscribers
388
+ };
389
+ }
390
+ // Annotate the CommonJS export names for ESM import in node:
391
+ 0 && (module.exports = {
392
+ getSocketStore,
393
+ useSocket
394
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,366 @@
1
+ // src/useSocket.ts
2
+ import { useEffect, useRef, useState, useCallback } from "react";
3
+
4
+ // src/hash.ts
5
+ function simpleHash(str) {
6
+ let hash = 0;
7
+ for (let i = 0; i < str.length; i++) {
8
+ const char = str.charCodeAt(i);
9
+ hash = (hash << 5) - hash + char;
10
+ hash = hash & hash;
11
+ }
12
+ return hash.toString(36);
13
+ }
14
+ function isDefaultParse(fn) {
15
+ const fnStr = fn.toString();
16
+ return fnStr.includes("JSON.parse") && fnStr.includes("event.data");
17
+ }
18
+ function isDefaultSerialize(fn) {
19
+ const fnStr = fn.toString();
20
+ return fnStr.includes("JSON.stringify") && !fnStr.includes("event");
21
+ }
22
+ function createSocketKey(url, config) {
23
+ const parts = [url];
24
+ if (config.protocols) {
25
+ const protocols = Array.isArray(config.protocols) ? config.protocols.sort().join(",") : config.protocols;
26
+ parts.push(`proto:${protocols}`);
27
+ }
28
+ parts.push(`ar:${config.autoReconnect}`);
29
+ parts.push(`ra:${config.reconnectAttempts}`);
30
+ parts.push(`rd:${config.reconnectDelay}`);
31
+ parts.push(`qm:${config.queueMessages}`);
32
+ parts.push(`mqs:${config.maxQueueSize}`);
33
+ if (config.key) {
34
+ parts.push(`key:${config.key}`);
35
+ } else {
36
+ const parseHash = isDefaultParse(config.parse) ? "default-parse" : simpleHash(config.parse.toString());
37
+ const serializeHash = isDefaultSerialize(config.serialize) ? "default-serialize" : simpleHash(config.serialize.toString());
38
+ parts.push(`ph:${parseHash}`);
39
+ parts.push(`sh:${serializeHash}`);
40
+ }
41
+ return parts.join("|");
42
+ }
43
+
44
+ // src/store.ts
45
+ var SocketStore = class {
46
+ constructor() {
47
+ this.sockets = /* @__PURE__ */ new Map();
48
+ }
49
+ /**
50
+ * Get a socket instance by key
51
+ */
52
+ get(key) {
53
+ return this.sockets.get(key);
54
+ }
55
+ /**
56
+ * Set a socket instance
57
+ */
58
+ set(key, instance) {
59
+ this.sockets.set(key, instance);
60
+ }
61
+ /**
62
+ * Delete a socket instance
63
+ */
64
+ delete(key) {
65
+ this.sockets.delete(key);
66
+ }
67
+ /**
68
+ * Check if a socket key exists
69
+ */
70
+ has(key) {
71
+ return this.sockets.has(key);
72
+ }
73
+ /**
74
+ * Get all socket keys
75
+ */
76
+ keys() {
77
+ return Array.from(this.sockets.keys());
78
+ }
79
+ /**
80
+ * Get read-only information about all sockets (for debugging/dashboard)
81
+ */
82
+ getAllSocketInfo() {
83
+ return Array.from(this.sockets.entries()).map(([key, instance]) => ({
84
+ key,
85
+ url: instance.socket?.url || "N/A",
86
+ status: instance.status,
87
+ refCount: instance.refCount,
88
+ queueLength: instance.messageQueue.length,
89
+ reconnectAttemptsMade: instance.reconnectAttemptsMade,
90
+ killed: instance.killed
91
+ }));
92
+ }
93
+ /**
94
+ * Clear a socket's message queue (for debugging/testing)
95
+ */
96
+ clearQueue(key) {
97
+ const instance = this.sockets.get(key);
98
+ if (instance) {
99
+ instance.messageQueue = [];
100
+ }
101
+ }
102
+ };
103
+ var socketStore = new SocketStore();
104
+ function getSocketStore() {
105
+ return {
106
+ getAllSocketInfo: () => socketStore.getAllSocketInfo(),
107
+ clearQueue: (key) => socketStore.clearQueue(key)
108
+ };
109
+ }
110
+
111
+ // src/useSocket.ts
112
+ var defaultParse = (event) => {
113
+ return JSON.parse(event.data);
114
+ };
115
+ var defaultSerialize = (data) => {
116
+ return JSON.stringify(data);
117
+ };
118
+ function normalizeOptions(options) {
119
+ return {
120
+ autoConnect: options?.autoConnect ?? false,
121
+ protocols: options?.protocols,
122
+ autoReconnect: options?.autoReconnect ?? false,
123
+ reconnectAttempts: options?.reconnectAttempts ?? Infinity,
124
+ reconnectDelay: options?.reconnectDelay ?? 1e3,
125
+ queueMessages: options?.queueMessages ?? false,
126
+ maxQueueSize: options?.maxQueueSize ?? 50,
127
+ parse: options?.parse ?? defaultParse,
128
+ serialize: options?.serialize ?? defaultSerialize,
129
+ key: options?.key
130
+ };
131
+ }
132
+ var subscriberIdCounter = 0;
133
+ function generateSubscriberId() {
134
+ return `sub-${++subscriberIdCounter}-${Date.now()}`;
135
+ }
136
+ function useSocket(url, options) {
137
+ const config = useRef(normalizeOptions(options)).current;
138
+ const socketKey = useRef(createSocketKey(url, config)).current;
139
+ const subscriberId = useRef(generateSubscriberId()).current;
140
+ const [status, setStatus] = useState("disconnected");
141
+ const [lastReturnedData, setLastReturnedData] = useState(void 0);
142
+ const [allData, setAllData] = useState([]);
143
+ const isConnectedRef = useRef(false);
144
+ const addToAllData = useCallback((data) => {
145
+ setAllData((prev) => [...prev, data]);
146
+ }, []);
147
+ const getOrCreateInstance = useCallback(() => {
148
+ let instance = socketStore.get(socketKey);
149
+ if (!instance) {
150
+ instance = {
151
+ socket: null,
152
+ key: socketKey,
153
+ status: "disconnected",
154
+ subscribers: /* @__PURE__ */ new Set(),
155
+ refCount: 0,
156
+ reconnectAttemptsMade: 0,
157
+ messageQueue: [],
158
+ config,
159
+ killed: false,
160
+ reconnectTimer: null
161
+ };
162
+ socketStore.set(socketKey, instance);
163
+ }
164
+ return instance;
165
+ }, [socketKey, config]);
166
+ const updateAllSubscribers = useCallback((instance, newStatus) => {
167
+ instance.status = newStatus;
168
+ instance.subscribers.forEach((sub) => {
169
+ sub.setStatus(newStatus);
170
+ });
171
+ }, []);
172
+ const flushQueue = useCallback((instance) => {
173
+ if (!instance.socket || instance.socket.readyState !== WebSocket.OPEN) {
174
+ return;
175
+ }
176
+ while (instance.messageQueue.length > 0) {
177
+ const data = instance.messageQueue.shift();
178
+ try {
179
+ const serialized = instance.config.serialize(data);
180
+ instance.socket.send(serialized);
181
+ } catch (error) {
182
+ console.error("[useSocket] Error flushing queued message:", error);
183
+ }
184
+ }
185
+ }, []);
186
+ const scheduleReconnect = useCallback((instance) => {
187
+ if (instance.reconnectTimer) {
188
+ clearTimeout(instance.reconnectTimer);
189
+ instance.reconnectTimer = null;
190
+ }
191
+ if (instance.killed || !instance.config.autoReconnect) {
192
+ return;
193
+ }
194
+ if (instance.reconnectAttemptsMade >= instance.config.reconnectAttempts) {
195
+ console.log("[useSocket] Reconnect attempts exhausted");
196
+ updateAllSubscribers(instance, "error");
197
+ return;
198
+ }
199
+ instance.reconnectAttemptsMade++;
200
+ const delay = instance.config.reconnectDelay * instance.reconnectAttemptsMade;
201
+ console.log(`[useSocket] Scheduling reconnect attempt ${instance.reconnectAttemptsMade} in ${delay}ms`);
202
+ updateAllSubscribers(instance, "reconnecting");
203
+ instance.reconnectTimer = setTimeout(() => {
204
+ instance.reconnectTimer = null;
205
+ connectSocket(instance);
206
+ }, delay);
207
+ }, [updateAllSubscribers]);
208
+ const connectSocket = useCallback((instance) => {
209
+ if (instance.socket?.readyState === WebSocket.OPEN) {
210
+ updateAllSubscribers(instance, "connected");
211
+ return;
212
+ }
213
+ if (instance.socket?.readyState === WebSocket.CONNECTING) {
214
+ updateAllSubscribers(instance, "connecting");
215
+ return;
216
+ }
217
+ if (instance.reconnectTimer) {
218
+ clearTimeout(instance.reconnectTimer);
219
+ instance.reconnectTimer = null;
220
+ }
221
+ try {
222
+ updateAllSubscribers(instance, "connecting");
223
+ const ws = new WebSocket(url, instance.config.protocols);
224
+ instance.socket = ws;
225
+ ws.onopen = () => {
226
+ console.log("[useSocket] Connected:", socketKey);
227
+ instance.reconnectAttemptsMade = 0;
228
+ updateAllSubscribers(instance, "connected");
229
+ flushQueue(instance);
230
+ };
231
+ ws.onmessage = (event) => {
232
+ try {
233
+ const data = instance.config.parse(event);
234
+ instance.subscribers.forEach((sub) => {
235
+ if (sub.isConnected) {
236
+ sub.setLastReturnedData(data);
237
+ sub.addToAllData(data);
238
+ }
239
+ });
240
+ } catch (error) {
241
+ console.error("[useSocket] Parse error:", error);
242
+ }
243
+ };
244
+ ws.onerror = (error) => {
245
+ console.error("[useSocket] WebSocket error:", error);
246
+ updateAllSubscribers(instance, "error");
247
+ };
248
+ ws.onclose = () => {
249
+ console.log("[useSocket] Disconnected:", socketKey);
250
+ instance.socket = null;
251
+ if (!instance.killed && instance.config.autoReconnect && instance.refCount > 0) {
252
+ scheduleReconnect(instance);
253
+ } else {
254
+ updateAllSubscribers(instance, "disconnected");
255
+ }
256
+ };
257
+ } catch (error) {
258
+ console.error("[useSocket] Connection error:", error);
259
+ updateAllSubscribers(instance, "error");
260
+ if (!instance.killed && instance.config.autoReconnect) {
261
+ scheduleReconnect(instance);
262
+ }
263
+ }
264
+ }, [url, socketKey, updateAllSubscribers, flushQueue, scheduleReconnect]);
265
+ const connect = useCallback(() => {
266
+ const instance = getOrCreateInstance();
267
+ if (!isConnectedRef.current) {
268
+ const subscriber = {
269
+ id: subscriberId,
270
+ isConnected: true,
271
+ setStatus,
272
+ setLastReturnedData,
273
+ addToAllData
274
+ };
275
+ instance.subscribers.add(subscriber);
276
+ instance.refCount++;
277
+ isConnectedRef.current = true;
278
+ setStatus(instance.status);
279
+ }
280
+ instance.killed = false;
281
+ connectSocket(instance);
282
+ }, [getOrCreateInstance, subscriberId, addToAllData, connectSocket]);
283
+ const disconnect = useCallback(() => {
284
+ const instance = socketStore.get(socketKey);
285
+ if (!instance || !isConnectedRef.current) return;
286
+ const subscriberToRemove = Array.from(instance.subscribers).find(
287
+ (sub) => sub.id === subscriberId
288
+ );
289
+ if (subscriberToRemove) {
290
+ instance.subscribers.delete(subscriberToRemove);
291
+ instance.refCount--;
292
+ isConnectedRef.current = false;
293
+ }
294
+ if (instance.refCount <= 0) {
295
+ if (instance.reconnectTimer) {
296
+ clearTimeout(instance.reconnectTimer);
297
+ instance.reconnectTimer = null;
298
+ }
299
+ if (instance.socket) {
300
+ instance.socket.close();
301
+ instance.socket = null;
302
+ }
303
+ socketStore.delete(socketKey);
304
+ }
305
+ setStatus("disconnected");
306
+ }, [socketKey, subscriberId]);
307
+ const send = useCallback((data) => {
308
+ const instance = socketStore.get(socketKey);
309
+ if (!instance) {
310
+ console.error("[useSocket] Cannot send: no instance found");
311
+ return;
312
+ }
313
+ if (instance.socket?.readyState === WebSocket.OPEN) {
314
+ try {
315
+ const serialized = instance.config.serialize(data);
316
+ instance.socket.send(serialized);
317
+ } catch (error) {
318
+ console.error("[useSocket] Send error:", error);
319
+ }
320
+ } else if (instance.config.queueMessages) {
321
+ instance.messageQueue.push(data);
322
+ while (instance.messageQueue.length > instance.config.maxQueueSize) {
323
+ instance.messageQueue.shift();
324
+ }
325
+ console.log(`[useSocket] Message queued (${instance.messageQueue.length}/${instance.config.maxQueueSize})`);
326
+ } else {
327
+ console.error("[useSocket] Cannot send: not connected and queueMessages is false");
328
+ }
329
+ }, [socketKey]);
330
+ const killSocketForAllSubscribers = useCallback(() => {
331
+ const instance = socketStore.get(socketKey);
332
+ if (!instance) return;
333
+ console.log("[useSocket] Kill switch activated for:", socketKey);
334
+ instance.killed = true;
335
+ if (instance.reconnectTimer) {
336
+ clearTimeout(instance.reconnectTimer);
337
+ instance.reconnectTimer = null;
338
+ }
339
+ if (instance.socket) {
340
+ instance.socket.close();
341
+ instance.socket = null;
342
+ }
343
+ updateAllSubscribers(instance, "disconnected");
344
+ }, [socketKey, updateAllSubscribers]);
345
+ useEffect(() => {
346
+ if (config.autoConnect) {
347
+ connect();
348
+ }
349
+ return () => {
350
+ disconnect();
351
+ };
352
+ }, []);
353
+ return {
354
+ connect,
355
+ disconnect,
356
+ send,
357
+ status,
358
+ lastReturnedData,
359
+ allData,
360
+ killSocketForAllSubscribers
361
+ };
362
+ }
363
+ export {
364
+ getSocketStore,
365
+ useSocket
366
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "react-ws-kit",
3
+ "version": "1.0.0",
4
+ "description": "Production-quality typed WebSocket hook for React with intelligent connection sharing, auto-reconnect, and message queuing",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup src/index.ts --format cjs,esm --dts",
22
+ "prepublishOnly": "npm run build",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/sameerdewan/react-ws-kit.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/sameerdewan/react-ws-kit/issues"
32
+ },
33
+ "homepage": "https://github.com/sameerdewan/react-ws-kit#readme",
34
+ "author": "Sameer Dewan <dewansameer@protonmail.com>",
35
+ "peerDependencies": {
36
+ "react": ">=16.8.0"
37
+ },
38
+ "devDependencies": {
39
+ "@testing-library/react": "^14.1.2",
40
+ "@types/react": "^18.2.45",
41
+ "@vitest/ui": "^1.1.0",
42
+ "happy-dom": "^12.10.3",
43
+ "react": "^18.2.0",
44
+ "tsup": "^8.0.1",
45
+ "typescript": "^5.3.3",
46
+ "vitest": "^1.1.0"
47
+ },
48
+ "keywords": [
49
+ "react",
50
+ "websocket",
51
+ "hook",
52
+ "typescript",
53
+ "realtime",
54
+ "ws",
55
+ "connection-sharing",
56
+ "auto-reconnect",
57
+ "message-queue",
58
+ "typed-websocket",
59
+ "react-hook",
60
+ "websocket-client"
61
+ ],
62
+ "license": "MIT",
63
+ "sideEffects": false,
64
+ "engines": {
65
+ "node": ">=18.0.0"
66
+ }
67
+ }
68
+