react-realtime-hooks 1.0.2 → 1.0.4
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/README.md +12 -6
- package/dist/index.cjs +117 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +117 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/volkov85/react-realtime-hooks/actions/workflows/pages.yml)
|
|
6
6
|
[](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
|
|
7
7
|
[](https://www.typescriptlang.org/)
|
|
8
|
-
[](https://www.npmjs.com/package/react)
|
|
9
9
|
|
|
10
10
|
Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness.
|
|
11
11
|
|
|
@@ -60,7 +60,7 @@ npm install react-realtime-hooks
|
|
|
60
60
|
|
|
61
61
|
Peer dependency:
|
|
62
62
|
|
|
63
|
-
- `react@^
|
|
63
|
+
- `react@^19.0.0`
|
|
64
64
|
|
|
65
65
|
## How It Feels
|
|
66
66
|
|
|
@@ -350,11 +350,11 @@ export function NetworkIndicator() {
|
|
|
350
350
|
| `parseMessage` | `(event) => TIncoming` | raw `event.data` | Incoming parser |
|
|
351
351
|
| `serializeMessage` | `(message) => ...` | JSON/string passthrough | Outgoing serializer |
|
|
352
352
|
| `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
|
|
353
|
-
| `heartbeat` | `false \|
|
|
353
|
+
| `heartbeat` | `false \| UseWebSocketHeartbeatOptions` | disabled unless configured | Heartbeat configuration |
|
|
354
354
|
| `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on close |
|
|
355
355
|
| `onOpen` | `(event, socket) => void` | `undefined` | Open callback |
|
|
356
356
|
| `onMessage` | `(message, event) => void` | `undefined` | Message callback |
|
|
357
|
-
| `onError` | `(event) => void` | `undefined` |
|
|
357
|
+
| `onError` | `(event) => void` | `undefined` | Called for transport, heartbeat, and parse errors |
|
|
358
358
|
| `onClose` | `(event) => void` | `undefined` | Close callback |
|
|
359
359
|
|
|
360
360
|
### Result
|
|
@@ -375,6 +375,10 @@ export function NetworkIndicator() {
|
|
|
375
375
|
| `reconnect` | `() => void` | Manual reconnect |
|
|
376
376
|
| `send` | `(message) => boolean` | Sends an outgoing payload |
|
|
377
377
|
|
|
378
|
+
When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` and
|
|
379
|
+
`errorAction` to `"none"`, `"close"`, or `"reconnect"`. The default is
|
|
380
|
+
`"reconnect"` when reconnect is enabled and `"close"` otherwise.
|
|
381
|
+
|
|
378
382
|
</details>
|
|
379
383
|
|
|
380
384
|
<details>
|
|
@@ -393,7 +397,7 @@ export function NetworkIndicator() {
|
|
|
393
397
|
| `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on error |
|
|
394
398
|
| `onOpen` | `(event, source) => void` | `undefined` | Open callback |
|
|
395
399
|
| `onMessage` | `(message, event) => void` | `undefined` | Default `message` callback |
|
|
396
|
-
| `onError` | `(event) => void` | `undefined` |
|
|
400
|
+
| `onError` | `(event) => void` | `undefined` | Called for transport and parse errors |
|
|
397
401
|
| `onEvent` | `(eventName, message, event) => void` | `undefined` | Named event callback |
|
|
398
402
|
|
|
399
403
|
### Result
|
|
@@ -464,6 +468,7 @@ export function NetworkIndicator() {
|
|
|
464
468
|
| `startOnMount` | `boolean` | `true` | Starts immediately |
|
|
465
469
|
| `onBeat` | `() => void` | `undefined` | Called on every beat |
|
|
466
470
|
| `onTimeout` | `() => void` | `undefined` | Called on timeout |
|
|
471
|
+
| `onError` | `(error) => void` | `undefined` | Called when `beat()` throws or rejects |
|
|
467
472
|
|
|
468
473
|
### Result
|
|
469
474
|
|
|
@@ -507,7 +512,8 @@ export function NetworkIndicator() {
|
|
|
507
512
|
|
|
508
513
|
- `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
|
|
509
514
|
- `useWebSocket` heartbeat support is client-side. You still define your own server ping/pong protocol.
|
|
510
|
-
- If `parseMessage` throws, the hook closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
|
|
515
|
+
- If `parseMessage` throws, the hook calls `onError`, closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
|
|
516
|
+
- Stopping heartbeat clears timeout state and the previous beat/ack timestamps so a new session starts with fresh metrics.
|
|
511
517
|
- `connect: false` keeps the hook in `idle` until `open()` is called.
|
|
512
518
|
- Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
|
|
513
519
|
- No transport polyfills are bundled. Provide your own runtime support where needed.
|
package/dist/index.cjs
CHANGED
|
@@ -404,6 +404,7 @@ var useHeartbeat = (options) => {
|
|
|
404
404
|
const startOnMount = options.startOnMount ?? true;
|
|
405
405
|
const intervalRef = react.useRef(createManagedInterval());
|
|
406
406
|
const timeoutRef = react.useRef(createManagedTimeout());
|
|
407
|
+
const generationRef = react.useRef(0);
|
|
407
408
|
const [state, setState] = react.useState(
|
|
408
409
|
() => createInitialState2(enabled && startOnMount)
|
|
409
410
|
);
|
|
@@ -430,8 +431,7 @@ var useHeartbeat = (options) => {
|
|
|
430
431
|
handleTimeout();
|
|
431
432
|
}, options.timeoutMs);
|
|
432
433
|
});
|
|
433
|
-
const
|
|
434
|
-
const performedAt = Date.now();
|
|
434
|
+
const handleBeatSuccess = react.useEffectEvent((performedAt) => {
|
|
435
435
|
commitState((current) => ({
|
|
436
436
|
...current,
|
|
437
437
|
hasTimedOut: false,
|
|
@@ -439,7 +439,35 @@ var useHeartbeat = (options) => {
|
|
|
439
439
|
}));
|
|
440
440
|
scheduleTimeout();
|
|
441
441
|
options.onBeat?.();
|
|
442
|
-
|
|
442
|
+
});
|
|
443
|
+
const handleBeatError = react.useEffectEvent((error) => {
|
|
444
|
+
timeoutRef.current.cancel();
|
|
445
|
+
options.onError?.(error);
|
|
446
|
+
});
|
|
447
|
+
const runBeat = react.useEffectEvent(() => {
|
|
448
|
+
const generation = generationRef.current;
|
|
449
|
+
const completeBeat = (result) => {
|
|
450
|
+
if (generation !== generationRef.current || result === false) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
handleBeatSuccess(Date.now());
|
|
454
|
+
};
|
|
455
|
+
const failBeat = (error) => {
|
|
456
|
+
if (generation !== generationRef.current) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
handleBeatError(error);
|
|
460
|
+
};
|
|
461
|
+
try {
|
|
462
|
+
const result = options.beat?.();
|
|
463
|
+
if (result !== null && typeof result === "object" && "then" in result) {
|
|
464
|
+
void Promise.resolve(result).then(completeBeat, failBeat);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
completeBeat(result);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
failBeat(error);
|
|
470
|
+
}
|
|
443
471
|
});
|
|
444
472
|
const start = () => {
|
|
445
473
|
if (!enabled) {
|
|
@@ -457,13 +485,10 @@ var useHeartbeat = (options) => {
|
|
|
457
485
|
}));
|
|
458
486
|
};
|
|
459
487
|
const stop = () => {
|
|
488
|
+
generationRef.current += 1;
|
|
460
489
|
intervalRef.current.cancel();
|
|
461
490
|
timeoutRef.current.cancel();
|
|
462
|
-
commitState((
|
|
463
|
-
...current,
|
|
464
|
-
hasTimedOut: false,
|
|
465
|
-
isRunning: false
|
|
466
|
-
}));
|
|
491
|
+
commitState(createInitialState2(false));
|
|
467
492
|
};
|
|
468
493
|
const beat = () => {
|
|
469
494
|
if (!enabled) {
|
|
@@ -502,6 +527,7 @@ var useHeartbeat = (options) => {
|
|
|
502
527
|
return void 0;
|
|
503
528
|
}, [enabled, options.intervalMs, startOnMount]);
|
|
504
529
|
react.useEffect(() => () => {
|
|
530
|
+
generationRef.current += 1;
|
|
505
531
|
intervalRef.current.cancel();
|
|
506
532
|
timeoutRef.current.cancel();
|
|
507
533
|
}, []);
|
|
@@ -607,6 +633,7 @@ var toProtocolsDependency = (protocols) => {
|
|
|
607
633
|
return Array.isArray(protocols) ? protocols.join("|") : protocols;
|
|
608
634
|
};
|
|
609
635
|
var toHeartbeatConfig = (heartbeat) => heartbeat === void 0 || heartbeat === false ? null : heartbeat;
|
|
636
|
+
var isSocketActive = (socket) => socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING;
|
|
610
637
|
var useWebSocket = (options) => {
|
|
611
638
|
const connect = options.connect ?? true;
|
|
612
639
|
const supported = isWebSocketSupported();
|
|
@@ -618,6 +645,7 @@ var useWebSocket = (options) => {
|
|
|
618
645
|
const manualOpenRef = react.useRef(false);
|
|
619
646
|
const skipCloseReconnectRef = react.useRef(false);
|
|
620
647
|
const suppressReconnectRef = react.useRef(false);
|
|
648
|
+
const pendingCloseActionRef = react.useRef(null);
|
|
621
649
|
const terminalErrorRef = react.useRef(null);
|
|
622
650
|
const [openNonce, setOpenNonce] = react.useState(0);
|
|
623
651
|
const [state, setState] = react.useState(
|
|
@@ -636,6 +664,7 @@ var useWebSocket = (options) => {
|
|
|
636
664
|
const heartbeatConfig = toHeartbeatConfig(
|
|
637
665
|
options.heartbeat
|
|
638
666
|
);
|
|
667
|
+
const defaultHeartbeatAction = options.reconnect === false ? "close" : "reconnect";
|
|
639
668
|
const heartbeatHookOptions = heartbeatConfig === null ? {
|
|
640
669
|
enabled: false,
|
|
641
670
|
intervalMs: 1e3,
|
|
@@ -671,6 +700,27 @@ var useWebSocket = (options) => {
|
|
|
671
700
|
if (heartbeatConfig !== null && heartbeatConfig.onTimeout !== void 0) {
|
|
672
701
|
heartbeatHookOptions.onTimeout = heartbeatConfig.onTimeout;
|
|
673
702
|
}
|
|
703
|
+
if (heartbeatConfig !== null) {
|
|
704
|
+
const onTimeout = heartbeatHookOptions.onTimeout;
|
|
705
|
+
heartbeatHookOptions.onTimeout = () => {
|
|
706
|
+
applyHeartbeatAction(
|
|
707
|
+
heartbeatConfig.timeoutAction ?? defaultHeartbeatAction,
|
|
708
|
+
new Event("heartbeat-timeout"),
|
|
709
|
+
"heartbeat-timeout"
|
|
710
|
+
);
|
|
711
|
+
onTimeout?.();
|
|
712
|
+
};
|
|
713
|
+
const onError = heartbeatConfig.onError;
|
|
714
|
+
heartbeatHookOptions.onError = (error) => {
|
|
715
|
+
const event = error instanceof Event ? error : new Event("heartbeat-error");
|
|
716
|
+
applyHeartbeatAction(
|
|
717
|
+
heartbeatConfig.errorAction ?? heartbeatConfig.timeoutAction ?? defaultHeartbeatAction,
|
|
718
|
+
event,
|
|
719
|
+
"error"
|
|
720
|
+
);
|
|
721
|
+
onError?.(error);
|
|
722
|
+
};
|
|
723
|
+
}
|
|
674
724
|
const heartbeat = useHeartbeat(
|
|
675
725
|
heartbeatHookOptions
|
|
676
726
|
);
|
|
@@ -686,10 +736,47 @@ var useWebSocket = (options) => {
|
|
|
686
736
|
}
|
|
687
737
|
socketRef.current = null;
|
|
688
738
|
socketKeyRef.current = null;
|
|
689
|
-
if (socket2
|
|
739
|
+
if (isSocketActive(socket2)) {
|
|
690
740
|
socket2.close(code, reason);
|
|
691
741
|
}
|
|
692
742
|
});
|
|
743
|
+
const applyHeartbeatAction = react.useEffectEvent(
|
|
744
|
+
(action, error, reconnectTrigger) => {
|
|
745
|
+
heartbeat.stop();
|
|
746
|
+
options.onError?.(error);
|
|
747
|
+
if (action === "none") {
|
|
748
|
+
commitState((current) => ({
|
|
749
|
+
...current,
|
|
750
|
+
lastChangedAt: Date.now(),
|
|
751
|
+
lastError: error
|
|
752
|
+
}));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const shouldReconnect = action === "reconnect" && reconnectEnabled && (options.shouldReconnect?.(error) ?? true);
|
|
756
|
+
manualOpenRef.current = false;
|
|
757
|
+
terminalErrorRef.current = shouldReconnect ? null : error;
|
|
758
|
+
const socket2 = socketRef.current;
|
|
759
|
+
if (socket2 === null || !isSocketActive(socket2)) {
|
|
760
|
+
commitState((current) => ({
|
|
761
|
+
...current,
|
|
762
|
+
lastChangedAt: Date.now(),
|
|
763
|
+
lastError: error,
|
|
764
|
+
status: shouldReconnect ? "reconnecting" : "error"
|
|
765
|
+
}));
|
|
766
|
+
if (shouldReconnect) {
|
|
767
|
+
reconnect.schedule(reconnectTrigger);
|
|
768
|
+
}
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
pendingCloseActionRef.current = {
|
|
772
|
+
error,
|
|
773
|
+
reconnectTrigger: shouldReconnect ? reconnectTrigger : null
|
|
774
|
+
};
|
|
775
|
+
skipCloseReconnectRef.current = true;
|
|
776
|
+
suppressReconnectRef.current = true;
|
|
777
|
+
closeSocket();
|
|
778
|
+
}
|
|
779
|
+
);
|
|
693
780
|
const parseMessage = react.useEffectEvent((event) => {
|
|
694
781
|
const parser = options.parseMessage ?? defaultParseMessage;
|
|
695
782
|
return parser(event);
|
|
@@ -734,6 +821,7 @@ var useWebSocket = (options) => {
|
|
|
734
821
|
suppressReconnectRef.current = true;
|
|
735
822
|
reconnect.cancel();
|
|
736
823
|
heartbeat.stop();
|
|
824
|
+
options.onError?.(parseError);
|
|
737
825
|
commitState((current) => ({
|
|
738
826
|
...current,
|
|
739
827
|
lastChangedAt: Date.now(),
|
|
@@ -747,6 +835,7 @@ var useWebSocket = (options) => {
|
|
|
747
835
|
heartbeat.stop();
|
|
748
836
|
commitState((current) => ({
|
|
749
837
|
...current,
|
|
838
|
+
lastChangedAt: Date.now(),
|
|
750
839
|
lastError: event,
|
|
751
840
|
status: "error"
|
|
752
841
|
}));
|
|
@@ -757,9 +846,26 @@ var useWebSocket = (options) => {
|
|
|
757
846
|
socketKeyRef.current = null;
|
|
758
847
|
heartbeat.stop();
|
|
759
848
|
updateBufferedAmount();
|
|
849
|
+
const pendingCloseAction = pendingCloseActionRef.current;
|
|
850
|
+
pendingCloseActionRef.current = null;
|
|
760
851
|
const terminalError = terminalErrorRef.current;
|
|
761
852
|
const skipCloseReconnect = skipCloseReconnectRef.current;
|
|
762
853
|
skipCloseReconnectRef.current = false;
|
|
854
|
+
if (pendingCloseAction !== null) {
|
|
855
|
+
suppressReconnectRef.current = false;
|
|
856
|
+
commitState((current) => ({
|
|
857
|
+
...current,
|
|
858
|
+
lastChangedAt: Date.now(),
|
|
859
|
+
lastCloseEvent: event,
|
|
860
|
+
lastError: pendingCloseAction.error ?? current.lastError,
|
|
861
|
+
status: pendingCloseAction.reconnectTrigger === null ? "error" : "reconnecting"
|
|
862
|
+
}));
|
|
863
|
+
options.onClose?.(event);
|
|
864
|
+
if (pendingCloseAction.reconnectTrigger !== null) {
|
|
865
|
+
reconnect.schedule(pendingCloseAction.reconnectTrigger);
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
763
869
|
if (terminalError !== null) {
|
|
764
870
|
suppressReconnectRef.current = false;
|
|
765
871
|
commitState((current) => ({
|
|
@@ -918,7 +1024,7 @@ var useWebSocket = (options) => {
|
|
|
918
1024
|
if (socket2 === null) {
|
|
919
1025
|
return;
|
|
920
1026
|
}
|
|
921
|
-
if (socket2
|
|
1027
|
+
if (isSocketActive(socket2)) {
|
|
922
1028
|
socket2.close();
|
|
923
1029
|
}
|
|
924
1030
|
}, []);
|
|
@@ -1076,6 +1182,7 @@ var useEventSource = (options) => {
|
|
|
1076
1182
|
suppressReconnectRef.current = true;
|
|
1077
1183
|
reconnect.cancel();
|
|
1078
1184
|
closeEventSource();
|
|
1185
|
+
options.onError?.(parseError);
|
|
1079
1186
|
commitState((current) => ({
|
|
1080
1187
|
...current,
|
|
1081
1188
|
lastChangedAt: Date.now(),
|