@qoretechnologies/reqraft 0.3.4 → 0.4.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.
@@ -0,0 +1,146 @@
1
+ import { useState } from 'react';
2
+ import { useEffectOnce, useUnmount } from 'react-use';
3
+ import { IReqraftWebSocketConfig, ReqraftWebSocket } from '../../utils/websocket';
4
+
5
+ export interface IUseReqraftWebSocketOptions extends IReqraftWebSocketConfig {
6
+ onMessage?: (ev: MessageEvent) => void;
7
+ useState?: boolean;
8
+ includeSentMessagesInState?: boolean;
9
+ includeLogMessagesInState?: boolean;
10
+ openOnMount?: boolean;
11
+ closeOnUnmount?: boolean;
12
+ }
13
+
14
+ export interface IUseReqraftWebSocket {
15
+ messages: string[];
16
+ status: keyof typeof ReqraftWebSocketStatus;
17
+ open: () => void;
18
+ close: () => void;
19
+ socket: ReqraftWebSocket;
20
+ send: (data: string) => void;
21
+ clear: () => void;
22
+ on: (type: keyof WebSocketEventMap, handler: (ev: Event) => void) => void;
23
+ addMessage: (message: string) => void;
24
+ }
25
+
26
+ export enum ReqraftWebSocketStatus {
27
+ OPEN = 'OPEN',
28
+ CLOSED = 'CLOSED',
29
+ CONNECTING = 'CONNECTING',
30
+ }
31
+
32
+ export const useReqraftWebSocket = (options: IUseReqraftWebSocketOptions): IUseReqraftWebSocket => {
33
+ const [messages, setMessages] = useState<string[]>([]);
34
+ const [status, setStatus] = useState<keyof typeof ReqraftWebSocketStatus>('CLOSED');
35
+ const [socket, setSocket] = useState<ReqraftWebSocket>(undefined);
36
+
37
+ const updateStates = (status: keyof typeof ReqraftWebSocketStatus, log?: string) => {
38
+ setStatus(status);
39
+
40
+ if (log && options?.includeLogMessagesInState && options?.useState) {
41
+ setMessages((prev) => [...prev, log]);
42
+ }
43
+ };
44
+
45
+ const handleOpen = (ev?: Event) => {
46
+ updateStates(ReqraftWebSocketStatus.OPEN, 'Connection opened');
47
+
48
+ options?.onOpen?.(ev);
49
+ };
50
+
51
+ const open = () => {
52
+ const socket = new ReqraftWebSocket({
53
+ ...options,
54
+ onOpen: handleOpen,
55
+ onMessage: (ev) => {
56
+ if (options?.useState) {
57
+ setMessages((prev) => [...prev, ev.data]);
58
+ }
59
+
60
+ options?.onMessage?.(ev);
61
+ },
62
+ onClose: (...args) => {
63
+ updateStates(ReqraftWebSocketStatus.CLOSED, 'Connection closed');
64
+
65
+ options?.onClose?.(...args);
66
+ },
67
+ onError: (...args) => {
68
+ updateStates(ReqraftWebSocketStatus.CLOSED, 'Connection error');
69
+
70
+ options?.onError?.(...args);
71
+ },
72
+ onReconnecting: (reconnectNumber) => {
73
+ updateStates(
74
+ ReqraftWebSocketStatus.CONNECTING,
75
+ `Reconnecting... Attempt ${reconnectNumber}`
76
+ );
77
+
78
+ options?.onReconnecting?.(reconnectNumber);
79
+ },
80
+ onReconnectFailed: () => {
81
+ updateStates(ReqraftWebSocketStatus.CLOSED, 'Reconnect failed');
82
+
83
+ options?.onReconnectFailed?.();
84
+ },
85
+ });
86
+
87
+ setSocket(socket);
88
+ };
89
+
90
+ const close = () => {
91
+ socket?.remove();
92
+ setSocket(undefined);
93
+ setStatus(ReqraftWebSocketStatus.CLOSED);
94
+ };
95
+
96
+ const send = (data: string) => {
97
+ socket?.send(data);
98
+
99
+ if (options?.includeSentMessagesInState) {
100
+ setMessages((prev) => [...prev, data]);
101
+ }
102
+ };
103
+
104
+ const on = (type: keyof WebSocketEventMap, handler: (ev: Event) => void) => {
105
+ // Special case for message event
106
+ // We want to handle it differently
107
+ // We want to filter out the ping messages
108
+ if (type === 'message') {
109
+ socket?.addHandler('message', (ev) => {
110
+ if ((<MessageEvent>ev).data === 'pong') {
111
+ return;
112
+ }
113
+
114
+ handler(ev);
115
+ });
116
+
117
+ return;
118
+ }
119
+
120
+ socket?.addHandler(type, handler);
121
+ };
122
+
123
+ const clear = () => {
124
+ setMessages([]);
125
+ };
126
+
127
+ const addMessage = (message: string) => {
128
+ if (options?.useState) {
129
+ setMessages((prev) => [...prev, message]);
130
+ }
131
+ };
132
+
133
+ useEffectOnce(() => {
134
+ if (options?.openOnMount) {
135
+ open();
136
+ }
137
+ });
138
+
139
+ useUnmount(() => {
140
+ if (options?.closeOnUnmount) {
141
+ close();
142
+ }
143
+ });
144
+
145
+ return { messages, status, open, socket, close, send, clear, on, addMessage };
146
+ };
@@ -0,0 +1,481 @@
1
+ import { ReqoreControlGroup, ReqoreP, ReqorePanel } from '@qoretechnologies/reqore';
2
+ import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
3
+ import { StoryObj } from '@storybook/react';
4
+ import { expect, fn, waitFor, within } from '@storybook/test';
5
+ import { Server } from 'mock-socket';
6
+ import { useEffect, useState } from 'react';
7
+ import { useMount } from 'react-use';
8
+ import { sleep, testsClickButton, testsWaitForText } from '../../../__tests__/utils';
9
+ import { StoryMeta } from '../../types';
10
+ import { ReqraftWebSocketsManager } from '../../utils/websocket';
11
+ import { IUseReqraftWebSocketOptions, useReqraftWebSocket } from './useWebSocket';
12
+
13
+ const CompWithHook = (args: IUseReqraftWebSocketOptions) => {
14
+ const { status, open, close, send, messages, clear } = useReqraftWebSocket(args);
15
+
16
+ return (
17
+ <ReqorePanel
18
+ minimal
19
+ size='small'
20
+ label={`Websocket Status: ${status}`}
21
+ actions={[
22
+ { label: 'Connect', icon: 'PlayLine', onClick: open },
23
+ { label: 'Disconnect', icon: 'StopLine', onClick: close },
24
+ { label: 'Clear', icon: 'CloseLine', onClick: clear },
25
+ { label: 'Kill', icon: 'CloseLine', onClick: () => send('kill') },
26
+ { label: 'Send', icon: 'MessageLine', onClick: () => send('This is a test message') },
27
+ ]}
28
+ >
29
+ {args.includeLogMessagesInState || args.useState ?
30
+ <ReqoreControlGroup vertical>
31
+ {messages.map((message, index) => (
32
+ <ReqoreP key={index}>{message}</ReqoreP>
33
+ ))}
34
+ </ReqoreControlGroup>
35
+ : null}
36
+ </ReqorePanel>
37
+ );
38
+ };
39
+
40
+ const meta = {
41
+ title: 'Hooks/useWebSocket',
42
+ async beforeEach() {
43
+ const url = `wss://hq.qoretechnologies.com:8092/log-test?token=${process.env.REACT_APP_QORUS_TOKEN}`;
44
+ let server = new Server(url);
45
+ let killTimeout: NodeJS.Timeout;
46
+
47
+ server.on('connection', (socket) => {
48
+ if (killTimeout) {
49
+ server.close();
50
+ return;
51
+ }
52
+
53
+ socket.on('message', (data) => {
54
+ if (data === 'ping') {
55
+ socket.send('pong');
56
+ return;
57
+ }
58
+
59
+ if (data === 'kill') {
60
+ server.close();
61
+
62
+ killTimeout = setTimeout(() => {
63
+ server = new Server(url);
64
+ killTimeout = null;
65
+ }, 3000);
66
+
67
+ return;
68
+ }
69
+
70
+ socket.send(`Received message: ${data}`);
71
+ });
72
+ });
73
+
74
+ return () => {
75
+ killTimeout && clearTimeout(killTimeout);
76
+ killTimeout = null;
77
+ server.close();
78
+ };
79
+ },
80
+ args: {
81
+ onOpen: fn(),
82
+ onMessage: fn(),
83
+ onClose: fn(),
84
+ onReconnecting: fn(),
85
+ onError: fn(),
86
+ onReconnectFailed: fn(),
87
+ reconnect: false,
88
+ closeOnUnmount: true,
89
+ url: 'log-test',
90
+ },
91
+ parameters: {
92
+ chromatic: { disable: true },
93
+ },
94
+ render: (args) => {
95
+ return <CompWithHook {...args} />;
96
+ },
97
+ } as StoryMeta<any, IUseReqraftWebSocketOptions>;
98
+
99
+ export default meta;
100
+ export type Story = StoryObj<typeof meta>;
101
+
102
+ export const Default: Story = {};
103
+ export const OpenManually: Story = {
104
+ play: async ({ args }) => {
105
+ await testsClickButton({ label: 'Connect' });
106
+ await testsWaitForText('Websocket Status: OPEN');
107
+ await expect(args.onOpen).toHaveBeenCalled();
108
+ },
109
+ };
110
+ export const OpenOnMount: Story = {
111
+ args: {
112
+ openOnMount: true,
113
+ },
114
+ play: async ({ args }) => {
115
+ await testsWaitForText('Websocket Status: OPEN');
116
+ await expect(args.onOpen).toHaveBeenCalled();
117
+ },
118
+ };
119
+
120
+ export const CloseManually: Story = {
121
+ ...OpenOnMount,
122
+ play: async ({ args, ...rest }) => {
123
+ await OpenOnMount.play({ args, ...rest });
124
+ await testsClickButton({ label: 'Disconnect' });
125
+ await testsWaitForText('Websocket Status: CLOSED');
126
+ await sleep(300);
127
+ await expect(args.onClose).toHaveBeenCalled();
128
+ },
129
+ };
130
+
131
+ export const Reconnects: Story = {
132
+ args: {
133
+ reconnect: true,
134
+ maxReconnectTries: 5,
135
+ openOnMount: true,
136
+ },
137
+ play: async ({ args, ...rest }) => {
138
+ await OpenOnMount.play({ args, ...rest });
139
+ await testsClickButton({ label: 'Kill' });
140
+ await testsWaitForText('Websocket Status: CONNECTING');
141
+ await expect(args.onReconnecting).toHaveBeenCalled();
142
+ await testsWaitForText('Websocket Status: OPEN');
143
+ await expect(args.onOpen).toHaveBeenCalled();
144
+ },
145
+ };
146
+
147
+ export const ReconnectFails: Story = {
148
+ args: {
149
+ reconnect: true,
150
+ maxReconnectTries: 3,
151
+ openOnMount: true,
152
+ reconnectInterval: 500,
153
+ },
154
+ play: async ({ args, ...rest }) => {
155
+ await OpenOnMount.play({ args, ...rest });
156
+ await testsClickButton({ label: 'Kill' });
157
+ await testsWaitForText('Websocket Status: CONNECTING');
158
+ await expect(args.onReconnecting).toHaveBeenCalled();
159
+ await testsWaitForText('Websocket Status: CLOSED');
160
+ await waitFor(() => expect(args.onReconnectFailed).toHaveBeenCalled(), { timeout: 10000 });
161
+ },
162
+ };
163
+
164
+ export const SendMessage: Story = {
165
+ args: {
166
+ ...OpenOnMount.args,
167
+ includeSentMessagesInState: true,
168
+ useState: true,
169
+ },
170
+ play: async ({ args, ...rest }) => {
171
+ await OpenOnMount.play({ args, ...rest });
172
+ await testsClickButton({ label: 'Send' });
173
+
174
+ await sleep(300);
175
+
176
+ await expect(args.onMessage).toHaveBeenCalledWith(
177
+ expect.objectContaining({ data: 'Received message: This is a test message' })
178
+ );
179
+ },
180
+ };
181
+
182
+ export const WithLogs: Story = {
183
+ args: {
184
+ ...Reconnects.args,
185
+ includeLogMessagesInState: true,
186
+ useState: true,
187
+ reconnectInterval: 500,
188
+ },
189
+ play: async ({ args, ...rest }) => {
190
+ await Reconnects.play({ args, ...rest });
191
+
192
+ await testsWaitForText('Reconnecting... Attempt 4');
193
+ await testsWaitForText('Connection opened');
194
+ },
195
+ };
196
+
197
+ export const ClearsMessages: Story = {
198
+ ...SendMessage,
199
+ play: async ({ args, ...rest }) => {
200
+ await SendMessage.play({ args, ...rest });
201
+ await testsClickButton({ label: 'Clear' });
202
+ await sleep(300);
203
+ },
204
+ };
205
+
206
+ interface IConnectionProps extends IUseReqraftWebSocketOptions {
207
+ onPanelClose?: () => void;
208
+ }
209
+
210
+ const ConnectionOne = ({ onPanelClose, ...args }: IConnectionProps) => {
211
+ const { status, open, close, send, messages, clear, on, addMessage } = useReqraftWebSocket({
212
+ ...args,
213
+ });
214
+
215
+ useEffect(() => {
216
+ if (status === 'OPEN') {
217
+ on('message', () => {
218
+ addMessage('I HAVE JUST RECEIVED A MESSAGE HA!');
219
+ });
220
+ }
221
+ }, [status]);
222
+
223
+ return (
224
+ <ReqorePanel
225
+ minimal
226
+ fluid
227
+ onClose={onPanelClose}
228
+ closeButtonProps={{
229
+ className: 'close-button',
230
+ }}
231
+ size='small'
232
+ label={`First Connection Status: ${status}`}
233
+ actions={[
234
+ { label: 'Connect', icon: 'PlayLine', onClick: open },
235
+ { label: 'Disconnect', icon: 'StopLine', onClick: close },
236
+ { label: 'Clear', icon: 'CloseLine', onClick: clear },
237
+ { label: 'Kill', icon: 'CloseLine', onClick: () => send('kill') },
238
+ { label: 'Send', icon: 'MessageLine', onClick: () => send('This is a test message') },
239
+ ]}
240
+ >
241
+ {args.includeLogMessagesInState || args.useState ?
242
+ <ReqoreControlGroup vertical>
243
+ {messages.map((message, index) => (
244
+ <ReqoreP key={index}>{message}</ReqoreP>
245
+ ))}
246
+ </ReqoreControlGroup>
247
+ : null}
248
+ </ReqorePanel>
249
+ );
250
+ };
251
+
252
+ const ConnectionTwo = ({ onPanelClose, ...args }: IConnectionProps) => {
253
+ const { status, open, close, send, messages, clear, on, addMessage } = useReqraftWebSocket(args);
254
+
255
+ useEffect(() => {
256
+ if (status === 'OPEN') {
257
+ on('close', () => {
258
+ addMessage('Why did you close it?!');
259
+ });
260
+ }
261
+ }, [status]);
262
+
263
+ return (
264
+ <ReqorePanel
265
+ minimal
266
+ fluid
267
+ size='small'
268
+ onClose={onPanelClose}
269
+ closeButtonProps={{
270
+ className: 'close-button',
271
+ }}
272
+ label={`Second Connection Status: ${status}`}
273
+ actions={[
274
+ { label: 'Connect', icon: 'PlayLine', onClick: open },
275
+ { label: 'Disconnect', icon: 'StopLine', onClick: close },
276
+ { label: 'Clear', icon: 'CloseLine', onClick: clear },
277
+ { label: 'Kill', icon: 'CloseLine', onClick: () => send('kill') },
278
+ { label: 'Send', icon: 'MessageLine', onClick: () => send('This is a test message') },
279
+ ]}
280
+ >
281
+ {args.includeLogMessagesInState || args.useState ?
282
+ <ReqoreControlGroup vertical>
283
+ {messages.map((message, index) => (
284
+ <ReqoreP key={index}>{message}</ReqoreP>
285
+ ))}
286
+ </ReqoreControlGroup>
287
+ : null}
288
+ </ReqorePanel>
289
+ );
290
+ };
291
+
292
+ const ConnectionThree = ({ onPanelClose, ...args }: IConnectionProps) => {
293
+ const { status, open, close, send, messages, clear, on, addMessage } = useReqraftWebSocket({
294
+ ...args,
295
+ });
296
+
297
+ useEffect(() => {
298
+ if (status === 'OPEN') {
299
+ on('message', () => {
300
+ addMessage('I ALSO HAVE A CUSTOM HANDLER FOR MESSAGES!');
301
+ });
302
+ }
303
+ }, [status]);
304
+
305
+ return (
306
+ <ReqorePanel
307
+ minimal
308
+ fluid
309
+ intent={
310
+ status === 'CLOSED' ? 'danger'
311
+ : status === 'CONNECTING' ?
312
+ 'pending'
313
+ : ('success' as TReqoreIntent)
314
+ }
315
+ size='small'
316
+ closeButtonProps={{
317
+ className: 'close-button',
318
+ }}
319
+ onClose={onPanelClose}
320
+ label={`Third Connection Status: ${status}`}
321
+ actions={[
322
+ { label: 'Connect', icon: 'PlayLine', onClick: open },
323
+ { label: 'Disconnect', icon: 'StopLine', onClick: close },
324
+ { label: 'Clear', icon: 'CloseLine', onClick: clear },
325
+ { label: 'Kill', icon: 'CloseLine', onClick: () => send('kill') },
326
+ { label: 'Send', icon: 'MessageLine', onClick: () => send('This is a test message') },
327
+ ]}
328
+ >
329
+ {args.includeLogMessagesInState || args.useState ?
330
+ <ReqoreControlGroup vertical>
331
+ {messages.map((message, index) => (
332
+ <ReqoreP key={index}>{message}</ReqoreP>
333
+ ))}
334
+ </ReqoreControlGroup>
335
+ : null}
336
+ </ReqorePanel>
337
+ );
338
+ };
339
+
340
+ export const MultipleConnections: Story = {
341
+ args: {
342
+ includeLogMessagesInState: true,
343
+ useState: true,
344
+ },
345
+ // @ts-expect-error customprops
346
+ render: (args: IUseReqraftWebSocketOptions) => {
347
+ const [conectionStatus, setConnectionStatus] = useState<string>('CLOSED');
348
+ const [panels, setPanels] = useState({ 1: true, 2: true, 3: true });
349
+
350
+ useMount(() => {
351
+ setConnectionStatus(
352
+ ReqraftWebSocketsManager.connections[args.url]?.socket ? 'OPEN' : 'CLOSED'
353
+ );
354
+ });
355
+
356
+ useEffect(() => {
357
+ setTimeout(() => {
358
+ setConnectionStatus(
359
+ ReqraftWebSocketsManager.connections[args.url]?.socket ? 'OPEN' : 'CLOSED'
360
+ );
361
+ }, 500);
362
+ }, [panels]);
363
+
364
+ const handlePanelClose = (panel: number) => {
365
+ setPanels((prev) => ({ ...prev, [panel]: false }));
366
+ };
367
+
368
+ return (
369
+ <ReqorePanel
370
+ minimal
371
+ flat
372
+ size='small'
373
+ label={`Multiple Connections: ${conectionStatus}`}
374
+ actions={[
375
+ {
376
+ label: 'Close All',
377
+ onClick: () => ReqraftWebSocketsManager.connections[args.url].socket.close(),
378
+ },
379
+ ]}
380
+ >
381
+ <ReqoreControlGroup vertical>
382
+ {panels[1] && <ConnectionOne {...args} onPanelClose={() => handlePanelClose(1)} />}
383
+ {panels[2] && <ConnectionTwo {...args} onPanelClose={() => handlePanelClose(2)} />}
384
+ {panels[3] && <ConnectionThree {...args} onPanelClose={() => handlePanelClose(3)} />}
385
+ </ReqoreControlGroup>
386
+ </ReqorePanel>
387
+ );
388
+ },
389
+ };
390
+
391
+ export const MultipleConnectionsOpenOnMount: Story = {
392
+ ...MultipleConnections,
393
+ args: {
394
+ ...MultipleConnections.args,
395
+ openOnMount: true,
396
+ },
397
+ play: async ({ args }) => {
398
+ await testsWaitForText('First Connection Status: OPEN');
399
+ await testsWaitForText('Second Connection Status: OPEN');
400
+ await testsWaitForText('Third Connection Status: OPEN');
401
+
402
+ await expect(args.onOpen).toHaveBeenCalled();
403
+ },
404
+ };
405
+
406
+ export const MultipleConnectionsClosedAtOnce: Story = {
407
+ ...MultipleConnections,
408
+ args: {
409
+ ...MultipleConnections.args,
410
+ openOnMount: true,
411
+ },
412
+ play: async (args) => {
413
+ await MultipleConnectionsOpenOnMount.play(args);
414
+
415
+ await testsClickButton({ label: 'Close All' });
416
+
417
+ await testsWaitForText('Multiple Connections: CLOSED');
418
+ await testsWaitForText('Why did you close it?!');
419
+ },
420
+ };
421
+
422
+ export const ConnectionIsClosedWhenAllUsersAreClosed: Story = {
423
+ ...MultipleConnections,
424
+ args: {
425
+ ...MultipleConnections.args,
426
+ openOnMount: true,
427
+ },
428
+ play: async (args) => {
429
+ await MultipleConnectionsOpenOnMount.play(args);
430
+
431
+ await testsClickButton({ selector: '.close-button' });
432
+ await testsWaitForText('Multiple Connections: OPEN');
433
+ await testsClickButton({ selector: '.close-button' });
434
+ await testsWaitForText('Multiple Connections: OPEN');
435
+ await testsClickButton({ selector: '.close-button' });
436
+ await testsWaitForText('Multiple Connections: CLOSED');
437
+ },
438
+ };
439
+
440
+ export const MultipleConnectionsHaveCustomHandlers: Story = {
441
+ ...MultipleConnections,
442
+ args: {
443
+ ...MultipleConnections.args,
444
+ openOnMount: true,
445
+ },
446
+ play: async (args) => {
447
+ const canvas = within(args.canvasElement);
448
+
449
+ await MultipleConnectionsOpenOnMount.play(args);
450
+
451
+ await testsClickButton({ label: 'Send' });
452
+
453
+ await testsWaitForText('I HAVE JUST RECEIVED A MESSAGE HA!');
454
+ await testsWaitForText('I ALSO HAVE A CUSTOM HANDLER FOR MESSAGES!');
455
+
456
+ // Disconnect the 3rd connection
457
+ await testsClickButton({ label: 'Disconnect', nth: 2 });
458
+ await testsClickButton({ label: 'Send' });
459
+ await testsClickButton({ label: 'Send' });
460
+
461
+ await sleep(500);
462
+
463
+ await expect(canvas.queryAllByText('I HAVE JUST RECEIVED A MESSAGE HA!')).toHaveLength(3);
464
+ await expect(canvas.queryAllByText('I ALSO HAVE A CUSTOM HANDLER FOR MESSAGES!')).toHaveLength(
465
+ 1
466
+ );
467
+ },
468
+ };
469
+
470
+ export const MultipleConnectionsCanBeDisconnectedAndReconnected: Story = {
471
+ ...MultipleConnectionsHaveCustomHandlers,
472
+ play: async (args) => {
473
+ await MultipleConnectionsHaveCustomHandlers.play(args);
474
+
475
+ await testsClickButton({ label: 'Disconnect', nth: 1 });
476
+ await testsClickButton({ label: 'Connect', nth: 2 });
477
+
478
+ await testsWaitForText('Second Connection Status: CLOSED');
479
+ await testsWaitForText('Third Connection Status: OPEN');
480
+ },
481
+ };
package/src/index.tsx CHANGED
@@ -9,9 +9,11 @@ export {
9
9
 
10
10
  export { IReqraftUseFetch, useFetch } from './hooks/useFetch/useFetch';
11
11
  export { TReqraftUseStorage, useReqraftStorage } from './hooks/useStorage/useStorage';
12
+ export * from './hooks/useWebSocket/useWebSocket';
12
13
  export {
13
14
  ReqraftProvider,
14
15
  ReqraftQueryClient,
15
16
  initializeReqraft,
16
17
  } from './providers/ReqraftProvider';
17
18
  export { query } from './utils/fetch';
19
+ export * from './utils/websocket';