Skip to content

Commit a85552f

Browse files
committed
feat: add useSse hooks
1 parent 05b8faa commit a85552f

File tree

7 files changed

+411
-0
lines changed

7 files changed

+411
-0
lines changed

config/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const menus = [
3232
'useTextSelection',
3333
'useWebSocket',
3434
'useTheme',
35+
'useSse',
3536
],
3637
},
3738
{

packages/hooks/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import useUpdateEffect from './useUpdateEffect';
7373
import useUpdateLayoutEffect from './useUpdateLayoutEffect';
7474
import useVirtualList from './useVirtualList';
7575
import useWebSocket from './useWebSocket';
76+
import useSse from './useSse';
7677
import useWhyDidYouUpdate from './useWhyDidYouUpdate';
7778
import useMutationObserver from './useMutationObserver';
7879
import useTheme from './useTheme';
@@ -133,6 +134,7 @@ export {
133134
useFavicon,
134135
useCountDown,
135136
useWebSocket,
137+
useSse,
136138
useLockFn,
137139
useUnmountedRef,
138140
useExternal,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, describe, expect, test, vi } from 'vitest';
3+
import useSse, { ReadyState } from '../index';
4+
5+
class MockEventSource {
6+
url: string;
7+
withCredentials: boolean;
8+
readyState: number;
9+
onopen: ((this: EventSource, ev: Event) => any) | null = null;
10+
onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
11+
onerror: ((this: EventSource, ev: Event) => any) | null = null;
12+
private listeners: Record<string, Array<(ev: Event) => void>> = {};
13+
14+
static CONNECTING = 0;
15+
static OPEN = 1;
16+
static CLOSED = 2;
17+
18+
constructor(url: string, init?: EventSourceInit) {
19+
this.url = url;
20+
this.withCredentials = Boolean(init?.withCredentials);
21+
this.readyState = MockEventSource.CONNECTING;
22+
setTimeout(() => {
23+
this.readyState = MockEventSource.OPEN;
24+
this.onopen && this.onopen(new Event('open'));
25+
}, 10);
26+
}
27+
28+
addEventListener(type: string, listener: (ev: Event) => void) {
29+
if (!this.listeners[type]) this.listeners[type] = [];
30+
this.listeners[type].push(listener);
31+
}
32+
33+
dispatchEvent(type: string, event: Event) {
34+
this.listeners[type]?.forEach((l) => l(event));
35+
}
36+
37+
emitMessage(data: any) {
38+
this.onmessage && this.onmessage(new MessageEvent('message', { data }));
39+
}
40+
41+
emitError() {
42+
this.onerror && this.onerror(new Event('error'));
43+
}
44+
45+
close() {
46+
this.readyState = MockEventSource.CLOSED;
47+
}
48+
}
49+
50+
describe('useSse', () => {
51+
const OriginalEventSource = (globalThis as any).EventSource;
52+
53+
afterEach(() => {
54+
(globalThis as any).EventSource = OriginalEventSource;
55+
});
56+
57+
test('should connect and receive message', async () => {
58+
(globalThis as any).EventSource = MockEventSource as any;
59+
60+
const hooks = renderHook(() => useSse('/sse'));
61+
62+
// not manual: should start connecting immediately
63+
expect(hooks.result.current.readyState).toBe(ReadyState.Connecting);
64+
65+
await act(async () => {
66+
await new Promise((r) => setTimeout(r, 20));
67+
});
68+
69+
expect(hooks.result.current.readyState).toBe(ReadyState.Open);
70+
71+
act(() => {
72+
const es = hooks.result.current.eventSource as unknown as MockEventSource;
73+
es.emitMessage('hello');
74+
});
75+
expect(hooks.result.current.latestMessage?.data).toBe('hello');
76+
});
77+
78+
test('manual should not auto connect', async () => {
79+
(globalThis as any).EventSource = MockEventSource as any;
80+
81+
const hooks = renderHook(() => useSse('/sse', { manual: true }));
82+
expect(hooks.result.current.readyState).toBe(ReadyState.Closed);
83+
84+
await act(async () => {
85+
hooks.result.current.connect();
86+
await new Promise((r) => setTimeout(r, 20));
87+
});
88+
89+
expect(hooks.result.current.readyState).toBe(ReadyState.Open);
90+
});
91+
92+
test('disconnect should close', async () => {
93+
(globalThis as any).EventSource = MockEventSource as any;
94+
95+
const hooks = renderHook(() => useSse('/sse'));
96+
await act(async () => {
97+
await new Promise((r) => setTimeout(r, 20));
98+
});
99+
expect(hooks.result.current.readyState).toBe(ReadyState.Open);
100+
act(() => hooks.result.current.disconnect());
101+
expect(hooks.result.current.readyState).toBe(ReadyState.Closed);
102+
});
103+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React, { useMemo, useRef } from 'react';
2+
import { useSse } from 'ahooks';
3+
4+
enum ReadyState {
5+
Connecting = 0,
6+
Open = 1,
7+
Closed = 2,
8+
}
9+
10+
export default () => {
11+
const historyRef = useRef<any[]>([]);
12+
const { readyState, latestMessage, connect, disconnect } = useSse('/api/sse');
13+
14+
historyRef.current = useMemo(() => historyRef.current.concat(latestMessage), [latestMessage]);
15+
16+
return (
17+
<div>
18+
<button onClick={() => connect()} style={{ marginRight: 8 }}>
19+
{readyState === ReadyState.Connecting ? 'connecting' : 'connect'}
20+
</button>
21+
<button
22+
onClick={() => disconnect()}
23+
style={{ marginRight: 8 }}
24+
disabled={readyState !== ReadyState.Open}
25+
>
26+
disconnect
27+
</button>
28+
<div style={{ marginTop: 8 }}>readyState: {readyState}</div>
29+
<div style={{ marginTop: 8 }}>
30+
<p>received message: </p>
31+
{historyRef.current.map((m, i) => (
32+
<p key={i} style={{ wordWrap: 'break-word' }}>
33+
{m?.data}
34+
</p>
35+
))}
36+
</div>
37+
</div>
38+
);
39+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
nav:
3+
path: /hooks
4+
---
5+
6+
# useSse
7+
8+
Listen to Server-Sent Events (SSE) stream with auto reconnect and lifecycle helpers.
9+
10+
### Examples
11+
12+
```tsx
13+
import React, { useMemo, useRef } from 'react';
14+
import { useSse } from 'ahooks';
15+
16+
export default () => {
17+
const historyRef = useRef<any[]>([]);
18+
const { readyState, latestMessage, connect, disconnect } = useSse('/api/sse');
19+
20+
historyRef.current = useMemo(() => historyRef.current.concat(latestMessage), [latestMessage]);
21+
22+
return (
23+
<div>
24+
<button onClick={() => connect()} style={{ marginRight: 8 }}>connect</button>
25+
<button onClick={() => disconnect()} style={{ marginRight: 8 }}>disconnect</button>
26+
<div>readyState: {readyState}</div>
27+
<div style={{ marginTop: 8 }}>
28+
{historyRef.current.map((m, i) => (
29+
<p key={i}>{m?.data}</p>
30+
))}
31+
</div>
32+
</div>
33+
);
34+
};
35+
```
36+
37+
### API
38+
39+
```ts
40+
const { readyState, latestMessage, connect, disconnect, eventSource } = useSse(
41+
url: string,
42+
options?: {
43+
manual?: boolean;
44+
withCredentials?: boolean;
45+
reconnectLimit?: number;
46+
reconnectInterval?: number; // ms
47+
events?: string[]; // named events
48+
onOpen?: (ev: Event, instance: EventSource) => void;
49+
onMessage?: (msg: MessageEvent, instance: EventSource) => void;
50+
onError?: (ev: Event, instance: EventSource) => void;
51+
onEvent?: (eventName: string, ev: MessageEvent, instance: EventSource) => void;
52+
}
53+
)
54+
```

packages/hooks/src/useSse/index.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import useLatest from '../useLatest';
3+
import useMemoizedFn from '../useMemoizedFn';
4+
import useUnmount from '../useUnmount';
5+
6+
export enum ReadyState {
7+
Connecting = 0,
8+
Open = 1,
9+
Closed = 2,
10+
}
11+
12+
export interface Options {
13+
manual?: boolean;
14+
withCredentials?: boolean;
15+
reconnectLimit?: number;
16+
reconnectInterval?: number;
17+
events?: string[];
18+
onOpen?: (event: Event, instance: EventSource) => void;
19+
onMessage?: (message: MessageEvent, instance: EventSource) => void;
20+
onError?: (event: Event, instance: EventSource) => void;
21+
onEvent?: (eventName: string, event: MessageEvent, instance: EventSource) => void;
22+
}
23+
24+
export interface Result {
25+
latestMessage?: MessageEvent;
26+
connect: () => void;
27+
disconnect: () => void;
28+
readyState: ReadyState;
29+
eventSource?: EventSource;
30+
}
31+
32+
function useSse(url: string, options: Options = {}): Result {
33+
const {
34+
manual = false,
35+
withCredentials = false,
36+
reconnectLimit = 3,
37+
reconnectInterval = 3 * 1000,
38+
events = [],
39+
onOpen,
40+
onMessage,
41+
onError,
42+
onEvent,
43+
} = options;
44+
45+
const onOpenRef = useLatest(onOpen);
46+
const onMessageRef = useLatest(onMessage);
47+
const onErrorRef = useLatest(onError);
48+
const onEventRef = useLatest(onEvent);
49+
50+
const reconnectTimesRef = useRef(0);
51+
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
52+
const esRef = useRef<EventSource>(undefined);
53+
54+
const [latestMessage, setLatestMessage] = useState<MessageEvent>();
55+
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed);
56+
57+
const reconnect = () => {
58+
if (
59+
reconnectTimesRef.current < reconnectLimit &&
60+
esRef.current?.readyState !== ReadyState.Open
61+
) {
62+
if (reconnectTimerRef.current) {
63+
clearTimeout(reconnectTimerRef.current);
64+
}
65+
reconnectTimerRef.current = setTimeout(() => {
66+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
67+
connectEs();
68+
reconnectTimesRef.current++;
69+
}, reconnectInterval);
70+
}
71+
};
72+
73+
const bindNamedEvents = (es: EventSource) => {
74+
events.forEach((eventName) => {
75+
es.addEventListener(eventName, (e) => {
76+
onEventRef.current?.(eventName, e as MessageEvent, es);
77+
});
78+
});
79+
};
80+
81+
const connectEs = () => {
82+
if (reconnectTimerRef.current) {
83+
clearTimeout(reconnectTimerRef.current);
84+
}
85+
86+
if (esRef.current) {
87+
esRef.current.close();
88+
}
89+
90+
const es = new EventSource(url, { withCredentials });
91+
setReadyState(ReadyState.Connecting);
92+
93+
es.onopen = (event) => {
94+
if (esRef.current !== es) return;
95+
reconnectTimesRef.current = 0;
96+
setReadyState(ReadyState.Open);
97+
onOpenRef.current?.(event, es);
98+
};
99+
100+
es.onmessage = (message) => {
101+
if (esRef.current !== es) return;
102+
setLatestMessage(message);
103+
onMessageRef.current?.(message, es);
104+
};
105+
106+
es.onerror = (event) => {
107+
// Note: native EventSource auto-reconnects. We still provide a manual reconnect mechanism
108+
// to give users control when connection is closed or encounters persistent errors.
109+
onErrorRef.current?.(event, es);
110+
// If closed by server or network, try manual reconnect
111+
if (esRef.current === es && es.readyState === EventSource.CLOSED) {
112+
setReadyState(ReadyState.Closed);
113+
reconnect();
114+
} else {
115+
setReadyState((es.readyState as ReadyState) ?? ReadyState.Connecting);
116+
}
117+
};
118+
119+
bindNamedEvents(es);
120+
121+
esRef.current = es;
122+
};
123+
124+
const connect = () => {
125+
reconnectTimesRef.current = 0;
126+
connectEs();
127+
};
128+
129+
const disconnect = () => {
130+
if (reconnectTimerRef.current) {
131+
clearTimeout(reconnectTimerRef.current);
132+
}
133+
reconnectTimesRef.current = reconnectLimit;
134+
esRef.current?.close();
135+
esRef.current = undefined;
136+
setReadyState(ReadyState.Closed);
137+
};
138+
139+
useEffect(() => {
140+
if (!manual && url) {
141+
connect();
142+
}
143+
}, [url, manual, withCredentials]);
144+
145+
useUnmount(() => {
146+
disconnect();
147+
});
148+
149+
return {
150+
latestMessage,
151+
connect: useMemoizedFn(connect),
152+
disconnect: useMemoizedFn(disconnect),
153+
readyState,
154+
eventSource: esRef.current,
155+
};
156+
}
157+
158+
export default useSse;

0 commit comments

Comments
 (0)