Skip to content

Commit 3726dac

Browse files
nailgilmanovnailgilmanov
authored andcommitted
issue 320: add new hook useEventSource
1 parent 7bf77f5 commit 3726dac

File tree

5 files changed

+311
-0
lines changed

5 files changed

+311
-0
lines changed

packages/core/src/bundle/hooks/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export * from './useDocumentVisibility/useDocumentVisibility';
2828
export * from './useElementSize/useElementSize';
2929
export * from './useEvent/useEvent';
3030
export * from './useEventListener/useEventListener';
31+
export * from './useEventSource/useEventSource';
3132
export * from './useEyeDropper/useEyeDropper';
3233
export * from './useFavicon/useFavicon';
3334
export * from './useField/useField';
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
function resolveNestedOptions(options) {
3+
if (options === true) return {};
4+
return options;
5+
}
6+
/**
7+
* Reactive wrapper for EventSource in React
8+
*
9+
* @param url The URL of the EventSource
10+
* @param events List of events to listen to
11+
* @param options Configuration options
12+
*/
13+
export function useEventSource(url, events = [], options = {}) {
14+
const [data, setData] = useState(null);
15+
const [status, setStatus] = useState('CONNECTING');
16+
const [event, setEvent] = useState(null);
17+
const [error, setError] = useState(null);
18+
const [lastEventId, setLastEventId] = useState(null);
19+
const eventSourceRef = useRef(null);
20+
const explicitlyClosedRef = useRef(false);
21+
const retriedRef = useRef(0);
22+
const { withCredentials = false, immediate = true, autoConnect = true, autoReconnect } = options;
23+
const close = useCallback(() => {
24+
if (eventSourceRef.current) {
25+
eventSourceRef.current.close();
26+
eventSourceRef.current = null;
27+
setStatus('CLOSED');
28+
explicitlyClosedRef.current = true;
29+
}
30+
}, []);
31+
const open = useCallback(() => {
32+
if (!url) return;
33+
close();
34+
explicitlyClosedRef.current = false;
35+
retriedRef.current = 0;
36+
const es = new EventSource(url, { withCredentials });
37+
eventSourceRef.current = es;
38+
setStatus('CONNECTING');
39+
es.onopen = () => {
40+
setStatus('OPEN');
41+
setError(null);
42+
};
43+
es.onerror = (e) => {
44+
setStatus('CLOSED');
45+
setError(e);
46+
// Reconnect logic
47+
if (es.readyState === 2 && !explicitlyClosedRef.current && autoReconnect) {
48+
es.close();
49+
const { retries = -1, delay = 1000, onFailed } = resolveNestedOptions(autoReconnect);
50+
retriedRef.current += 1;
51+
if (typeof retries === 'number' && (retries < 0 || retriedRef.current < retries)) {
52+
setTimeout(open, delay);
53+
} else if (typeof retries === 'function' && retries()) {
54+
setTimeout(open, delay);
55+
} else {
56+
onFailed?.();
57+
}
58+
}
59+
};
60+
es.onmessage = (e) => {
61+
setEvent(null);
62+
setData(e.data);
63+
setLastEventId(e.lastEventId);
64+
};
65+
events.forEach((eventName) => {
66+
es.addEventListener(eventName, (e) => {
67+
setEvent(eventName);
68+
setData(e.data || null);
69+
});
70+
});
71+
}, [url, withCredentials, autoReconnect, events, close]);
72+
useEffect(() => {
73+
if (immediate) open();
74+
return () => close();
75+
}, [immediate, open, close]);
76+
useEffect(() => {
77+
if (autoConnect) open();
78+
}, [url, autoConnect, open]);
79+
return {
80+
data,
81+
status,
82+
event,
83+
error,
84+
close,
85+
open,
86+
lastEventId
87+
};
88+
}

packages/core/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export * from './useDocumentVisibility/useDocumentVisibility';
2828
export * from './useElementSize/useElementSize';
2929
export * from './useEvent/useEvent';
3030
export * from './useEventListener/useEventListener';
31+
export * from './useEventSource/useEventSource';
3132
export * from './useEyeDropper/useEyeDropper';
3233
export * from './useFavicon/useFavicon';
3334
export * from './useField/useField';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useEventSource } from './useEventSource';
2+
3+
const Demo = () => {
4+
const { data, status, close, open } = useEventSource(
5+
'https://example.com/sse',
6+
['update', 'message'],
7+
{
8+
autoReconnect: {
9+
retries: 3,
10+
delay: 2000,
11+
onFailed: () => console.error('Reconnect failed'),
12+
},
13+
}
14+
);
15+
16+
return (
17+
<div>
18+
<p>Status: {status}</p>
19+
<p>Data: {data}</p>
20+
<button onClick={close}>Close Connection</button>
21+
<button onClick={open}>Reconnect</button>
22+
</div>
23+
);
24+
}
25+
26+
export default Demo;
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
type EventSourceStatus = 'CLOSED' | 'CONNECTING' | 'OPEN';
4+
5+
interface UseEventSourceOptions extends EventSourceInit {
6+
/**
7+
* Automatically connect to the EventSource when URL changes
8+
*
9+
* @default true
10+
*/
11+
autoConnect?: boolean;
12+
13+
/**
14+
* Immediately open the connection when calling this hook
15+
*
16+
* @default true
17+
*/
18+
immediate?: boolean;
19+
20+
/**
21+
* Enabled auto reconnect
22+
*
23+
* @default false
24+
*/
25+
autoReconnect?:
26+
| boolean
27+
| {
28+
/**
29+
* Maximum retry times.
30+
*
31+
* Or you can pass a predicate function (which returns true if you want to retry).
32+
*
33+
* @default -1
34+
*/
35+
retries?: (() => boolean) | number;
36+
37+
/**
38+
* Delay for reconnect, in milliseconds
39+
*
40+
* @default 1000
41+
*/
42+
delay?: number;
43+
44+
/**
45+
* On maximum retry times reached.
46+
*/
47+
onFailed?: () => void;
48+
};
49+
}
50+
51+
interface UseEventSourceReturn<Data = any> {
52+
/**
53+
* The latest data received via the EventSource
54+
*/
55+
data: Data | null;
56+
57+
/**
58+
* The current error
59+
*/
60+
error: Event | null;
61+
62+
/**
63+
* The latest named event
64+
*/
65+
event: string | null;
66+
67+
/**
68+
* The last event ID string, for server-sent events
69+
*/
70+
lastEventId: string | null;
71+
72+
/**
73+
* The current state of the connection
74+
*/
75+
status: EventSourceStatus;
76+
77+
/**
78+
* Closes the EventSource connection gracefully
79+
*/
80+
close: () => void;
81+
82+
/**
83+
* Reopen the EventSource connection
84+
*/
85+
open: () => void;
86+
}
87+
88+
function resolveNestedOptions<T>(options: true | T): T {
89+
if (options === true) return {} as T;
90+
return options;
91+
}
92+
93+
/**
94+
* Reactive wrapper for EventSource in React
95+
*
96+
* @param url The URL of the EventSource
97+
* @param events List of events to listen to
98+
* @param options Configuration options
99+
*/
100+
export function useEventSource<Data = any>(
101+
url: string | URL | undefined,
102+
events: string[] = [],
103+
options: UseEventSourceOptions = {}
104+
): UseEventSourceReturn<Data> {
105+
const [data, setData] = useState<Data | null>(null);
106+
const [status, setStatus] = useState<EventSourceStatus>('CONNECTING');
107+
const [event, setEvent] = useState<string | null>(null);
108+
const [error, setError] = useState<Event | null>(null);
109+
const [lastEventId, setLastEventId] = useState<string | null>(null);
110+
111+
const eventSourceRef = useRef<EventSource | null>(null);
112+
const explicitlyClosedRef = useRef(false);
113+
const retriedRef = useRef(0);
114+
115+
const { withCredentials = false, immediate = true, autoConnect = true, autoReconnect } = options;
116+
117+
const close = useCallback(() => {
118+
if (eventSourceRef.current) {
119+
eventSourceRef.current.close();
120+
eventSourceRef.current = null;
121+
setStatus('CLOSED');
122+
explicitlyClosedRef.current = true;
123+
}
124+
}, []);
125+
126+
const open = useCallback(() => {
127+
if (!url) return;
128+
129+
close();
130+
explicitlyClosedRef.current = false;
131+
retriedRef.current = 0;
132+
133+
const es = new EventSource(url, { withCredentials });
134+
eventSourceRef.current = es;
135+
136+
setStatus('CONNECTING');
137+
138+
es.onopen = () => {
139+
setStatus('OPEN');
140+
setError(null);
141+
};
142+
143+
es.onerror = (e) => {
144+
setStatus('CLOSED');
145+
setError(e);
146+
147+
// Reconnect logic
148+
if (es.readyState === 2 && !explicitlyClosedRef.current && autoReconnect) {
149+
es.close();
150+
const { retries = -1, delay = 1000, onFailed } = resolveNestedOptions(autoReconnect);
151+
retriedRef.current += 1;
152+
153+
if (typeof retries === 'number' && (retries < 0 || retriedRef.current < retries)) {
154+
setTimeout(open, delay);
155+
} else if (typeof retries === 'function' && retries()) {
156+
setTimeout(open, delay);
157+
} else {
158+
onFailed?.();
159+
}
160+
}
161+
};
162+
163+
es.onmessage = (e: MessageEvent) => {
164+
setEvent(null);
165+
setData(e.data);
166+
setLastEventId(e.lastEventId);
167+
};
168+
169+
events.forEach((eventName) => {
170+
es.addEventListener(eventName, (e: Event & { data?: Data }) => {
171+
setEvent(eventName);
172+
setData(e.data || null);
173+
});
174+
});
175+
}, [url, withCredentials, autoReconnect, events, close]);
176+
177+
useEffect(() => {
178+
if (immediate) open();
179+
return () => close();
180+
}, [immediate, open, close]);
181+
182+
useEffect(() => {
183+
if (autoConnect) open();
184+
}, [url, autoConnect, open]);
185+
186+
return {
187+
data,
188+
status,
189+
event,
190+
error,
191+
close,
192+
open,
193+
lastEventId
194+
};
195+
}

0 commit comments

Comments
 (0)