@rscc/common-react 0.4.0 → 0.5.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/README.md +47 -5
- package/dist/index.cjs +162 -5
- package/dist/index.d.cts +84 -2
- package/dist/index.d.ts +84 -2
- package/dist/index.js +165 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @rscc/common-react
|
|
2
2
|
|
|
3
|
-
RSCC 공통 React 훅 — `useDebounce`, `useSse`. [@rscc/common-core](https://www.npmjs.com/package/@rscc/common-core) 의
|
|
3
|
+
RSCC 공통 React 훅 — `useDebounce`, `useSse`, `useSseEvents`. [@rscc/common-core](https://www.npmjs.com/package/@rscc/common-core) 의
|
|
4
|
+
SSE 리더(채팅 프로필 `readSseStream`/`readSseChatEvents`, 범용 표준 `readSseEvents`)를 React 상태로 래핑한다.
|
|
4
5
|
|
|
5
6
|
## 설치
|
|
6
7
|
|
|
@@ -17,10 +18,12 @@ npm i @rscc/common-react
|
|
|
17
18
|
| 훅 | 시그니처 | 설명 |
|
|
18
19
|
|---|---|---|
|
|
19
20
|
| `useDebounce` | `useDebounce<T>(value, delayMs = 200): T` | 마지막 안정 값만 통과 — 자동완성 API 호출 절감 |
|
|
20
|
-
| `useSse` | `useSse(options?)` → `{ start, cancel, text, sources, conversationId, error, isStreaming, finishReason }` | SSE 구독 상태 훅 — 언마운트 시 자동 중단, `finishReason` 으로 종료 사유 구분, opt-in `reconnect`, 쿠키 인증 모드용 `credentials`·`csrf` |
|
|
21
|
+
| `useSse` | `useSse(options?)` → `{ start, cancel, text, sources, conversationId, error, isStreaming, finishReason }` | 채팅 SSE 구독 상태 훅 — 언마운트 시 자동 중단, `finishReason` 으로 종료 사유 구분, opt-in `reconnect`, 쿠키 인증 모드용 `credentials`·`csrf`, `parser: "standard"` 로 표준 파서 어댑터 선택 |
|
|
22
|
+
| `useSseEvents` | `useSseEvents(options?)` → `{ start, cancel, lastEvent, lastEventId, error, isStreaming, finishReason }` | 범용 SSE 구독 훅 (WHATWG `event`/`id`/`retry`) — `onEvent` 콜백 + `lastEvent` 1개만 상태 보관, `isTerminal` 종료 판정, opt-in `reconnect`(`Last-Event-ID` 이어받기·서버 `retry:` 존중) |
|
|
21
23
|
|
|
22
24
|
`UseSseOptions`: `fetchImpl?` · `reconnect?: SseReconnectOptions` · `credentials?: RequestCredentials` · `csrf?: boolean | CsrfOptions`
|
|
23
|
-
(`
|
|
25
|
+
· `parser?: "legacy" | "standard"` (기본 `"legacy"`). `UseSseEventsOptions`: `fetchImpl?` · `onEvent?(e)` · `isTerminal?(e)` ·
|
|
26
|
+
`reconnect?` · `credentials?` · `csrf?` (`CsrfOptions`·`SseEvent` 는 `@rscc/common-core` 에서 export).
|
|
24
27
|
|
|
25
28
|
## 사용 예시
|
|
26
29
|
|
|
@@ -68,9 +71,48 @@ const sse = useSse({ reconnect: { retries: 3, minDelayMs: 1000, maxDelayMs: 1500
|
|
|
68
71
|
- 재연결 트리거는 **"스트림 시작(`res.ok`) 후 `[DONE]` 없이 끊긴 경우"뿐** — `'done'`/`'aborted'`/
|
|
69
72
|
구독 전 실패(`!res.ok`)/in-band error 프레임 종료는 서버 의도로 보고 재연결하지 않는다.
|
|
70
73
|
- 각 시도는 **전체 재요청**이며 직전 시도의 `text`/`sources`/`conversationId`/`error` 를 초기화한다
|
|
71
|
-
(중복 누적 방지). 이어받기(Last-Event-ID)는 지원하지
|
|
74
|
+
(중복 누적 방지). 이어받기(Last-Event-ID)는 지원하지 않는다 — 범용 `useSseEvents` 는 지원한다.
|
|
72
75
|
- [주의] 비멱등 POST 스트림은 전체 재요청이 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것.
|
|
73
76
|
|
|
77
|
+
### useSse — 표준 파서 어댑터 (`parser: "standard"`)
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const sse = useSse({ parser: "standard" }); // core readSseChatEvents 사용
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- 기본 `"legacy"`(core `readSseStream`)는 현행 그대로 — 프레임 구분자 LF(`\n\n`) 고정, 첫 `data:` 라인만 읽는다.
|
|
84
|
+
- `"standard"` 는 WHATWG 표준 파서 위의 채팅 어댑터 — **CRLF·CR 줄 끝**, **멀티라인 `data`**, `[DONE]` 앞뒤 공백을
|
|
85
|
+
받아들인다. 개행을 CRLF 로 정규화하는 프록시 뒤라면 이 모드를 쓴다. 콜백·`finishReason`·재연결 시맨틱과 EOF 잔여 프레임
|
|
86
|
+
처리는 레거시와 같다.
|
|
87
|
+
|
|
88
|
+
### useSseEvents — 범용 SSE (알림·진행률·로그 등)
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { useSseEvents } from "@rscc/common-react";
|
|
92
|
+
import type { SseEvent } from "@rscc/common-core";
|
|
93
|
+
|
|
94
|
+
const { start, cancel, lastEvent, lastEventId, error, isStreaming, finishReason } = useSseEvents({
|
|
95
|
+
onEvent: (e: SseEvent) => appendLog(e), // 누적은 소비 측에서 — 훅은 lastEvent 1개만 보관
|
|
96
|
+
isTerminal: (e) => e.event === "end", // true → 연결 종료 + finishReason 'done'
|
|
97
|
+
reconnect: { retries: 5, minDelayMs: 1000 }, // opt-in — 기본 off
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await start("/api/v1/jobs/42/events");
|
|
101
|
+
// lastEvent: { event: "progress", data: "60", id: "17" } — 마지막 이벤트
|
|
102
|
+
// lastEventId: 재연결 시 Last-Event-ID 로 실리는 값
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `finishReason`: `'done'`(`isTerminal` 이벤트 또는 HTTP 204) / `'aborted'`(`cancel()`·재시작·언마운트) /
|
|
106
|
+
`'interrupted'`(종료 이벤트 없이 EOF·전송 오류·구독 전 실패·재연결 소진). `isTerminal` 이 없으면 모든 종료가 EOF 라 `'interrupted'`.
|
|
107
|
+
- **재연결**(opt-in)은 "스트림 시작 후 종료 이벤트 없이 끊김"에서만 — 전체 재요청이 아니라 **이어받기**다:
|
|
108
|
+
마지막 이벤트 ID 가 있으면 `Last-Event-ID` 헤더를 싣고(`init.headers` 와 병합), 서버가 `retry:` 를 보냈으면
|
|
109
|
+
계산된 백오프 대신 그 값(ms)을 대기로 쓴다. ID 는 연결을 넘어 유지된다(id 없는 이벤트도 이어받음 — EventSource 동일).
|
|
110
|
+
재연결 시 `lastEvent`/`lastEventId` 는 유지하고 `error` 만 초기화한다.
|
|
111
|
+
- 구독 전 실패(`!res.ok`)는 봉투 `message` 를 `error` 로 노출하고 재연결하지 않는다. HTTP 204 는 "더 없음"으로 보고 `'done'`.
|
|
112
|
+
- `onEvent`/`isTerminal` 은 이벤트 시점의 **최신 콜백**을 쓰므로 인라인 함수여도 `start` 참조가 바뀌지 않는다.
|
|
113
|
+
콜백이 throw 하면 스트림을 멈추고 `error` 로 노출하며 재연결하지 않는다.
|
|
114
|
+
- `credentials`·`csrf` 는 `useSse` 와 같은 규칙 (아래 쿠키 인증 모드 참조). 언마운트 시 진행 중 스트림을 중단한다(StrictMode 안전).
|
|
115
|
+
|
|
74
116
|
### useSse — 쿠키 인증 모드 (credentials / CSRF)
|
|
75
117
|
|
|
76
118
|
서버가 인증 자격을 HttpOnly 쿠키(`jwt-cookie`) 또는 서버 세션(`session`)으로 받는다면 `credentials` 와 `csrf` 를 켠다.
|
|
@@ -101,5 +143,5 @@ const sse2 = useSse({
|
|
|
101
143
|
|
|
102
144
|
## 관련 패키지
|
|
103
145
|
|
|
104
|
-
- 코어(SSE
|
|
146
|
+
- 코어(SSE `readSseStream`·`readSseChatEvents`·`readSseEvents`·apiClient 등): [@rscc/common-core](https://www.npmjs.com/package/@rscc/common-core)
|
|
105
147
|
- 라이선스: MIT
|
package/dist/index.cjs
CHANGED
|
@@ -21,7 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
useDebounce: () => useDebounce,
|
|
24
|
-
useSse: () => useSse
|
|
24
|
+
useSse: () => useSse,
|
|
25
|
+
useSseEvents: () => useSseEvents
|
|
25
26
|
});
|
|
26
27
|
module.exports = __toCommonJS(index_exports);
|
|
27
28
|
|
|
@@ -41,7 +42,7 @@ var import_react2 = require("react");
|
|
|
41
42
|
var import_common_core = require("@rscc/common-core");
|
|
42
43
|
var RECONNECT_MARKER = new Error("rscc-sse-reconnect");
|
|
43
44
|
function useSse(options = {}) {
|
|
44
|
-
const { fetchImpl, reconnect, credentials, csrf } = options;
|
|
45
|
+
const { fetchImpl, reconnect, credentials, csrf, parser = "legacy" } = options;
|
|
45
46
|
const [text, setText] = (0, import_react2.useState)("");
|
|
46
47
|
const [sources, setSources] = (0, import_react2.useState)(null);
|
|
47
48
|
const [conversationId, setConversationId] = (0, import_react2.useState)(null);
|
|
@@ -97,7 +98,8 @@ function useSse(options = {}) {
|
|
|
97
98
|
return;
|
|
98
99
|
}
|
|
99
100
|
streamStarted = true;
|
|
100
|
-
|
|
101
|
+
const readChat = parser === "standard" ? import_common_core.readSseChatEvents : import_common_core.readSseStream;
|
|
102
|
+
await readChat(res, {
|
|
101
103
|
onConversationId: setConversationId,
|
|
102
104
|
onSources: setSources,
|
|
103
105
|
onDelta: (d) => setText((prev) => prev + d),
|
|
@@ -156,13 +158,168 @@ function useSse(options = {}) {
|
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
},
|
|
159
|
-
[fetchImpl, reconnect, credentials, csrf]
|
|
161
|
+
[fetchImpl, reconnect, credentials, csrf, parser]
|
|
160
162
|
);
|
|
161
163
|
(0, import_react2.useEffect)(() => () => abortRef.current?.abort(), []);
|
|
162
164
|
return { start, cancel, text, sources, conversationId, error, isStreaming, finishReason };
|
|
163
165
|
}
|
|
166
|
+
|
|
167
|
+
// src/useSseEvents.ts
|
|
168
|
+
var import_react3 = require("react");
|
|
169
|
+
var import_common_core2 = require("@rscc/common-core");
|
|
170
|
+
var RECONNECT_MARKER2 = new Error("rscc-sse-events-reconnect");
|
|
171
|
+
var LAST_EVENT_ID_HEADER = "Last-Event-ID";
|
|
172
|
+
var messageOf = (err) => err instanceof Error ? err.message : String(err);
|
|
173
|
+
function useSseEvents(options = {}) {
|
|
174
|
+
const [lastEvent, setLastEvent] = (0, import_react3.useState)(null);
|
|
175
|
+
const [lastEventId, setLastEventId] = (0, import_react3.useState)("");
|
|
176
|
+
const [error, setError] = (0, import_react3.useState)(null);
|
|
177
|
+
const [isStreaming, setIsStreaming] = (0, import_react3.useState)(false);
|
|
178
|
+
const [finishReason, setFinishReason] = (0, import_react3.useState)(null);
|
|
179
|
+
const abortRef = (0, import_react3.useRef)(null);
|
|
180
|
+
const optionsRef = (0, import_react3.useRef)(options);
|
|
181
|
+
(0, import_react3.useEffect)(() => {
|
|
182
|
+
optionsRef.current = options;
|
|
183
|
+
});
|
|
184
|
+
const cancel = (0, import_react3.useCallback)(() => {
|
|
185
|
+
abortRef.current?.abort();
|
|
186
|
+
}, []);
|
|
187
|
+
const start = (0, import_react3.useCallback)(async (url, init) => {
|
|
188
|
+
abortRef.current?.abort();
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
abortRef.current = controller;
|
|
191
|
+
const { fetchImpl, reconnect, credentials, csrf } = optionsRef.current;
|
|
192
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
193
|
+
setLastEvent(null);
|
|
194
|
+
setLastEventId("");
|
|
195
|
+
setError(null);
|
|
196
|
+
setFinishReason(null);
|
|
197
|
+
setIsStreaming(true);
|
|
198
|
+
let currentLastEventId = "";
|
|
199
|
+
let serverRetryMs = null;
|
|
200
|
+
let reason = "interrupted";
|
|
201
|
+
const requestInit = () => {
|
|
202
|
+
const merged = { ...init, signal: controller.signal };
|
|
203
|
+
const effectiveCredentials = init?.credentials ?? credentials;
|
|
204
|
+
if (effectiveCredentials !== void 0) merged.credentials = effectiveCredentials;
|
|
205
|
+
let headers = null;
|
|
206
|
+
const ensureHeaders = () => {
|
|
207
|
+
if (headers === null) headers = new Headers(init?.headers);
|
|
208
|
+
return headers;
|
|
209
|
+
};
|
|
210
|
+
if (csrf) {
|
|
211
|
+
const csrfHeader = (0, import_common_core2.csrfHeaderFor)(url, init?.method, csrf);
|
|
212
|
+
if (csrfHeader && !ensureHeaders().has(csrfHeader[0])) {
|
|
213
|
+
ensureHeaders().set(csrfHeader[0], csrfHeader[1]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (currentLastEventId !== "") ensureHeaders().set(LAST_EVENT_ID_HEADER, currentLastEventId);
|
|
217
|
+
if (headers !== null) merged.headers = headers;
|
|
218
|
+
return merged;
|
|
219
|
+
};
|
|
220
|
+
const attemptStream = async (attempt) => {
|
|
221
|
+
if (attempt > 0) setError(null);
|
|
222
|
+
let terminal = false;
|
|
223
|
+
let streamStarted = false;
|
|
224
|
+
let callbackFailed = false;
|
|
225
|
+
let callbackError;
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetchFn(url, requestInit());
|
|
228
|
+
if (!res.ok) {
|
|
229
|
+
const json = await res.json().catch(() => null);
|
|
230
|
+
setError(typeof json?.message === "string" ? json.message : `HTTP ${res.status}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (res.status === 204) {
|
|
234
|
+
reason = "done";
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
streamStarted = true;
|
|
238
|
+
const result = await (0, import_common_core2.readSseEvents)(res, {
|
|
239
|
+
// 연결을 넘어 마지막 이벤트 ID 유지 (EventSource 동일) — id 없는 이벤트도 이어받는다.
|
|
240
|
+
lastEventId: currentLastEventId,
|
|
241
|
+
onEvent: (event) => {
|
|
242
|
+
currentLastEventId = event.id;
|
|
243
|
+
setLastEvent(event);
|
|
244
|
+
setLastEventId(event.id);
|
|
245
|
+
try {
|
|
246
|
+
optionsRef.current.onEvent?.(event);
|
|
247
|
+
if (optionsRef.current.isTerminal?.(event) === true) {
|
|
248
|
+
terminal = true;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
callbackFailed = true;
|
|
253
|
+
callbackError = err;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return void 0;
|
|
257
|
+
},
|
|
258
|
+
onRetry: (ms) => {
|
|
259
|
+
serverRetryMs = ms;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
currentLastEventId = result.lastEventId;
|
|
263
|
+
setLastEventId(result.lastEventId);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (controller.signal.aborted) {
|
|
266
|
+
reason = "aborted";
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
setError(messageOf(err));
|
|
270
|
+
if (streamStarted && reconnect) throw RECONNECT_MARKER2;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (controller.signal.aborted) {
|
|
274
|
+
reason = "aborted";
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (callbackFailed) {
|
|
278
|
+
setError(messageOf(callbackError));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (terminal) {
|
|
282
|
+
reason = "done";
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (reconnect) throw RECONNECT_MARKER2;
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
if (reconnect) {
|
|
289
|
+
await (0, import_common_core2.retry)(attemptStream, {
|
|
290
|
+
retries: reconnect.retries ?? 3,
|
|
291
|
+
minDelayMs: reconnect.minDelayMs ?? 1e3,
|
|
292
|
+
maxDelayMs: reconnect.maxDelayMs ?? 15e3,
|
|
293
|
+
factor: reconnect.factor ?? 2,
|
|
294
|
+
jitter: reconnect.jitter ?? true,
|
|
295
|
+
shouldRetry: (e) => e === RECONNECT_MARKER2,
|
|
296
|
+
// 서버 retry: 힌트가 있으면 그 값을 재연결 대기로 (WHATWG reconnection time).
|
|
297
|
+
overrideDelay: (_e, _attempt, delayMs) => serverRetryMs ?? delayMs,
|
|
298
|
+
signal: controller.signal
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
await attemptStream(0);
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (controller.signal.aborted) {
|
|
305
|
+
reason = "aborted";
|
|
306
|
+
} else if (err !== RECONNECT_MARKER2) {
|
|
307
|
+
setError(messageOf(err));
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
if (abortRef.current === controller) {
|
|
311
|
+
abortRef.current = null;
|
|
312
|
+
setIsStreaming(false);
|
|
313
|
+
setFinishReason(reason);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}, []);
|
|
317
|
+
(0, import_react3.useEffect)(() => () => abortRef.current?.abort(), []);
|
|
318
|
+
return { start, cancel, lastEvent, lastEventId, error, isStreaming, finishReason };
|
|
319
|
+
}
|
|
164
320
|
// Annotate the CommonJS export names for ESM import in node:
|
|
165
321
|
0 && (module.exports = {
|
|
166
322
|
useDebounce,
|
|
167
|
-
useSse
|
|
323
|
+
useSse,
|
|
324
|
+
useSseEvents
|
|
168
325
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CsrfOptions, SseSource } from '@rscc/common-core';
|
|
1
|
+
import { CsrfOptions, SseSource, SseEvent } from '@rscc/common-core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* value 가 delayMs(ms) 동안 더 이상 바뀌지 않으면 그 값을 반환한다.
|
|
@@ -25,6 +25,10 @@ type SseFinishReason = "done" | "aborted" | "interrupted";
|
|
|
25
25
|
* text/sources/conversationId/error 상태를 초기화한다 (중복 누적 방지).
|
|
26
26
|
* 이어받기(Last-Event-ID)는 비범위 — sse-frames.md 가 `event:`/`id:`/`retry:`
|
|
27
27
|
* 필드를 사용하지 않는다고 명시하므로 재개 지점이 존재하지 않는다.
|
|
28
|
+
*
|
|
29
|
+
* 범용 훅 `useSseEvents` 도 같은 옵션을 쓰되 시맨틱이 다르다 — 재연결은 전체 재요청이 아니라
|
|
30
|
+
* `Last-Event-ID` 이어받기이고, 서버 `retry:` 값이 있으면 계산된 백오프 대신 그 값을 대기로 쓴다
|
|
31
|
+
* (contracts/sse-events.md §3).
|
|
28
32
|
*/
|
|
29
33
|
interface SseReconnectOptions {
|
|
30
34
|
/** 최대 재연결 시도 횟수 (총 연결 = retries + 1). 기본 3. */
|
|
@@ -61,6 +65,13 @@ interface UseSseOptions {
|
|
|
61
65
|
* `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않는다. 재연결 시도마다 쿠키를 다시 읽는다.
|
|
62
66
|
*/
|
|
63
67
|
csrf?: boolean | CsrfOptions;
|
|
68
|
+
/**
|
|
69
|
+
* 채팅 프레임 리더 선택 (contracts/sse-events.md §4). 기본 `"legacy"` = core `readSseStream`(현행 동작 —
|
|
70
|
+
* LF `\n\n` 프레임 구분, 첫 `data:` 라인만). `"standard"` = core `readSseChatEvents` — WHATWG 표준 파서 위의
|
|
71
|
+
* 채팅 어댑터로 CRLF·CR 줄 끝과 멀티라인 `data` 를 받아들이고 `[DONE]` 비교 전 앞뒤 공백을 제거한다
|
|
72
|
+
* (콜백·종료 사유·재연결 시맨틱은 동일, EOF 잔여 프레임도 레거시처럼 처리).
|
|
73
|
+
*/
|
|
74
|
+
parser?: "legacy" | "standard";
|
|
64
75
|
}
|
|
65
76
|
interface UseSseReturn {
|
|
66
77
|
/**
|
|
@@ -84,6 +95,8 @@ interface UseSseReturn {
|
|
|
84
95
|
}
|
|
85
96
|
/**
|
|
86
97
|
* @rscc/common-core 의 readSseStream 을 감싼 상태 훅 (contracts/sse-frames.md).
|
|
98
|
+
* `parser: "standard"` 면 표준 파서 위의 readSseChatEvents 를 쓴다 (contracts/sse-events.md §4).
|
|
99
|
+
* 채팅 외의 범용 SSE(event/id/retry 필드)는 {@link import("./useSseEvents").useSseEvents} 를 쓴다.
|
|
87
100
|
*
|
|
88
101
|
* - 구독 전 검증 실패(!res.ok — CommonResponse JSON 에러 경로)는 봉투의 message 를 error 로 노출.
|
|
89
102
|
* - 스트림 시작 후 오류는 in-band error 프레임으로 수신되어 동일하게 error 로 노출.
|
|
@@ -95,4 +108,73 @@ interface UseSseReturn {
|
|
|
95
108
|
*/
|
|
96
109
|
declare function useSse(options?: UseSseOptions): UseSseReturn;
|
|
97
110
|
|
|
98
|
-
|
|
111
|
+
interface UseSseEventsOptions {
|
|
112
|
+
/** fetch 구현체 주입 (테스트용). 기본 globalThis.fetch. */
|
|
113
|
+
fetchImpl?: typeof fetch;
|
|
114
|
+
/**
|
|
115
|
+
* 이벤트마다 호출 — 누적이 필요하면 여기서 소비 측 상태에 쌓는다 (훅은 `lastEvent` 1개만 보관).
|
|
116
|
+
* 호출 시점의 최신 콜백을 쓴다(인라인 함수여도 start 재생성 없음). throw 하면 스트림을 멈추고
|
|
117
|
+
* error 로 노출하며 재연결하지 않는다.
|
|
118
|
+
*/
|
|
119
|
+
onEvent?: (event: SseEvent) => void;
|
|
120
|
+
/**
|
|
121
|
+
* 종료 이벤트 판정 — true 를 반환한 이벤트(onEvent 전달 후)에서 읽기를 멈추고 연결을 닫으며
|
|
122
|
+
* finishReason 'done' 으로 끝난다. 미지정이면 모든 종료가 EOF → 'interrupted' 다.
|
|
123
|
+
*/
|
|
124
|
+
isTerminal?: (event: SseEvent) => boolean;
|
|
125
|
+
/**
|
|
126
|
+
* 자동 재연결 — 기본 undefined = off (옵트인). 켜면 "스트림 시작(res.ok) 후 종료 이벤트 없이
|
|
127
|
+
* 끊김"(EOF·전송 오류)에서 재연결하며 (contracts/sse-events.md §3):
|
|
128
|
+
* - 마지막 이벤트 ID 가 있으면 `Last-Event-ID` 헤더를 싣는다 (`init.headers` 와 병합 — 같은 이름은 덮어씀).
|
|
129
|
+
* - 서버가 `retry:` 를 보냈으면 계산된 백오프 대신 그 값(ms)을 재연결 대기로 쓴다.
|
|
130
|
+
* - 구독 전 실패(!res.ok)·HTTP 204·종료 이벤트·abort·onEvent 예외는 재연결하지 않는다.
|
|
131
|
+
* 각 재연결은 이어받기이므로 `lastEvent`/`lastEventId` 는 유지하고 `error` 만 초기화한다.
|
|
132
|
+
*/
|
|
133
|
+
reconnect?: SseReconnectOptions;
|
|
134
|
+
/**
|
|
135
|
+
* fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
|
|
136
|
+
* 미지정 = 속성 자체를 넘기지 않음. `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
137
|
+
*/
|
|
138
|
+
credentials?: RequestCredentials;
|
|
139
|
+
/**
|
|
140
|
+
* CSRF double-submit 헤더 자동 부착 (core `csrfHeaderFor` — 골든 벡터 XC-01~08). 비안전 메서드 +
|
|
141
|
+
* CSRF 쿠키 + 같은 출처(또는 `allowedOrigins`)일 때만, `init.headers` 의 같은 헤더는 덮어쓰지 않는다.
|
|
142
|
+
* 재연결 시도마다 쿠키를 다시 읽는다.
|
|
143
|
+
*/
|
|
144
|
+
csrf?: boolean | CsrfOptions;
|
|
145
|
+
}
|
|
146
|
+
interface UseSseEventsReturn {
|
|
147
|
+
/**
|
|
148
|
+
* 구독 시작. 이전 스트림이 진행 중이면 중단 후 상태를 초기화한다. 반환 Promise 는 스트림 종료
|
|
149
|
+
* (종료 이벤트/중단/오류/재연결 소진) 시 resolve — 오류는 error 상태로 노출되며 reject 하지 않는다.
|
|
150
|
+
*/
|
|
151
|
+
start: (url: string, init?: RequestInit) => Promise<void>;
|
|
152
|
+
/** 진행 중인 스트림(또는 재연결 대기) 중단. */
|
|
153
|
+
cancel: () => void;
|
|
154
|
+
/** 마지막으로 받은 이벤트 (배열 누적 없음 — 누적은 onEvent 에서). 미수신 시 null. */
|
|
155
|
+
lastEvent: SseEvent | null;
|
|
156
|
+
/** 마지막 이벤트 ID (재연결 `Last-Event-ID` 값). 초기값 `""`. */
|
|
157
|
+
lastEventId: string;
|
|
158
|
+
/** 구독 전 실패 메시지(CommonResponse message 또는 `HTTP <status>`)·전송 오류·onEvent 예외 메시지. */
|
|
159
|
+
error: string | null;
|
|
160
|
+
isStreaming: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* 직전 스트림의 종료 사유 — 'done'(isTerminal 이벤트 또는 HTTP 204) / 'aborted'(cancel·재시작·언마운트) /
|
|
163
|
+
* 'interrupted'(종료 이벤트 없이 끝남 — EOF·전송 오류·구독 전 실패·재연결 소진). 스트리밍 중/시작 전엔 null.
|
|
164
|
+
*/
|
|
165
|
+
finishReason: SseFinishReason | null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 범용 SSE 구독 훅 — @rscc/common-core 의 표준 파서 리더 `readSseEvents` 를 감싼다
|
|
169
|
+
* (contracts/sse-events.md — WHATWG 이벤트 스트림, `event`/`id`/`retry` 필드).
|
|
170
|
+
*
|
|
171
|
+
* - 이벤트마다 `onEvent` 를 부르고 `lastEvent`·`lastEventId` 상태만 갱신한다 (무한 누적 배열 없음).
|
|
172
|
+
* - `isTerminal` 이벤트에서 연결을 닫고 'done', 그 없이 EOF 면 'interrupted'.
|
|
173
|
+
* - reconnect 옵트인 시 `Last-Event-ID` 이어받기 + 서버 `retry:` 존중 (core `retry()` 의 overrideDelay 훅).
|
|
174
|
+
* - 쿠키 인증 모드는 `credentials` + `csrf` (useSse 와 동일 규칙, 호출자 `init` 우선).
|
|
175
|
+
* - 언마운트·재시작 시 진행 중 스트림을 중단한다 (StrictMode 이중 마운트 안전 — useSse 와 같은 구조).
|
|
176
|
+
* - 채팅 프레임(sse-frames.md)은 {@link import("./useSse").useSse} 를 쓴다.
|
|
177
|
+
*/
|
|
178
|
+
declare function useSseEvents(options?: UseSseEventsOptions): UseSseEventsReturn;
|
|
179
|
+
|
|
180
|
+
export { type SseFinishReason, type SseReconnectOptions, type UseSseEventsOptions, type UseSseEventsReturn, type UseSseOptions, type UseSseReturn, useDebounce, useSse, useSseEvents };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CsrfOptions, SseSource } from '@rscc/common-core';
|
|
1
|
+
import { CsrfOptions, SseSource, SseEvent } from '@rscc/common-core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* value 가 delayMs(ms) 동안 더 이상 바뀌지 않으면 그 값을 반환한다.
|
|
@@ -25,6 +25,10 @@ type SseFinishReason = "done" | "aborted" | "interrupted";
|
|
|
25
25
|
* text/sources/conversationId/error 상태를 초기화한다 (중복 누적 방지).
|
|
26
26
|
* 이어받기(Last-Event-ID)는 비범위 — sse-frames.md 가 `event:`/`id:`/`retry:`
|
|
27
27
|
* 필드를 사용하지 않는다고 명시하므로 재개 지점이 존재하지 않는다.
|
|
28
|
+
*
|
|
29
|
+
* 범용 훅 `useSseEvents` 도 같은 옵션을 쓰되 시맨틱이 다르다 — 재연결은 전체 재요청이 아니라
|
|
30
|
+
* `Last-Event-ID` 이어받기이고, 서버 `retry:` 값이 있으면 계산된 백오프 대신 그 값을 대기로 쓴다
|
|
31
|
+
* (contracts/sse-events.md §3).
|
|
28
32
|
*/
|
|
29
33
|
interface SseReconnectOptions {
|
|
30
34
|
/** 최대 재연결 시도 횟수 (총 연결 = retries + 1). 기본 3. */
|
|
@@ -61,6 +65,13 @@ interface UseSseOptions {
|
|
|
61
65
|
* `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않는다. 재연결 시도마다 쿠키를 다시 읽는다.
|
|
62
66
|
*/
|
|
63
67
|
csrf?: boolean | CsrfOptions;
|
|
68
|
+
/**
|
|
69
|
+
* 채팅 프레임 리더 선택 (contracts/sse-events.md §4). 기본 `"legacy"` = core `readSseStream`(현행 동작 —
|
|
70
|
+
* LF `\n\n` 프레임 구분, 첫 `data:` 라인만). `"standard"` = core `readSseChatEvents` — WHATWG 표준 파서 위의
|
|
71
|
+
* 채팅 어댑터로 CRLF·CR 줄 끝과 멀티라인 `data` 를 받아들이고 `[DONE]` 비교 전 앞뒤 공백을 제거한다
|
|
72
|
+
* (콜백·종료 사유·재연결 시맨틱은 동일, EOF 잔여 프레임도 레거시처럼 처리).
|
|
73
|
+
*/
|
|
74
|
+
parser?: "legacy" | "standard";
|
|
64
75
|
}
|
|
65
76
|
interface UseSseReturn {
|
|
66
77
|
/**
|
|
@@ -84,6 +95,8 @@ interface UseSseReturn {
|
|
|
84
95
|
}
|
|
85
96
|
/**
|
|
86
97
|
* @rscc/common-core 의 readSseStream 을 감싼 상태 훅 (contracts/sse-frames.md).
|
|
98
|
+
* `parser: "standard"` 면 표준 파서 위의 readSseChatEvents 를 쓴다 (contracts/sse-events.md §4).
|
|
99
|
+
* 채팅 외의 범용 SSE(event/id/retry 필드)는 {@link import("./useSseEvents").useSseEvents} 를 쓴다.
|
|
87
100
|
*
|
|
88
101
|
* - 구독 전 검증 실패(!res.ok — CommonResponse JSON 에러 경로)는 봉투의 message 를 error 로 노출.
|
|
89
102
|
* - 스트림 시작 후 오류는 in-band error 프레임으로 수신되어 동일하게 error 로 노출.
|
|
@@ -95,4 +108,73 @@ interface UseSseReturn {
|
|
|
95
108
|
*/
|
|
96
109
|
declare function useSse(options?: UseSseOptions): UseSseReturn;
|
|
97
110
|
|
|
98
|
-
|
|
111
|
+
interface UseSseEventsOptions {
|
|
112
|
+
/** fetch 구현체 주입 (테스트용). 기본 globalThis.fetch. */
|
|
113
|
+
fetchImpl?: typeof fetch;
|
|
114
|
+
/**
|
|
115
|
+
* 이벤트마다 호출 — 누적이 필요하면 여기서 소비 측 상태에 쌓는다 (훅은 `lastEvent` 1개만 보관).
|
|
116
|
+
* 호출 시점의 최신 콜백을 쓴다(인라인 함수여도 start 재생성 없음). throw 하면 스트림을 멈추고
|
|
117
|
+
* error 로 노출하며 재연결하지 않는다.
|
|
118
|
+
*/
|
|
119
|
+
onEvent?: (event: SseEvent) => void;
|
|
120
|
+
/**
|
|
121
|
+
* 종료 이벤트 판정 — true 를 반환한 이벤트(onEvent 전달 후)에서 읽기를 멈추고 연결을 닫으며
|
|
122
|
+
* finishReason 'done' 으로 끝난다. 미지정이면 모든 종료가 EOF → 'interrupted' 다.
|
|
123
|
+
*/
|
|
124
|
+
isTerminal?: (event: SseEvent) => boolean;
|
|
125
|
+
/**
|
|
126
|
+
* 자동 재연결 — 기본 undefined = off (옵트인). 켜면 "스트림 시작(res.ok) 후 종료 이벤트 없이
|
|
127
|
+
* 끊김"(EOF·전송 오류)에서 재연결하며 (contracts/sse-events.md §3):
|
|
128
|
+
* - 마지막 이벤트 ID 가 있으면 `Last-Event-ID` 헤더를 싣는다 (`init.headers` 와 병합 — 같은 이름은 덮어씀).
|
|
129
|
+
* - 서버가 `retry:` 를 보냈으면 계산된 백오프 대신 그 값(ms)을 재연결 대기로 쓴다.
|
|
130
|
+
* - 구독 전 실패(!res.ok)·HTTP 204·종료 이벤트·abort·onEvent 예외는 재연결하지 않는다.
|
|
131
|
+
* 각 재연결은 이어받기이므로 `lastEvent`/`lastEventId` 는 유지하고 `error` 만 초기화한다.
|
|
132
|
+
*/
|
|
133
|
+
reconnect?: SseReconnectOptions;
|
|
134
|
+
/**
|
|
135
|
+
* fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
|
|
136
|
+
* 미지정 = 속성 자체를 넘기지 않음. `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
137
|
+
*/
|
|
138
|
+
credentials?: RequestCredentials;
|
|
139
|
+
/**
|
|
140
|
+
* CSRF double-submit 헤더 자동 부착 (core `csrfHeaderFor` — 골든 벡터 XC-01~08). 비안전 메서드 +
|
|
141
|
+
* CSRF 쿠키 + 같은 출처(또는 `allowedOrigins`)일 때만, `init.headers` 의 같은 헤더는 덮어쓰지 않는다.
|
|
142
|
+
* 재연결 시도마다 쿠키를 다시 읽는다.
|
|
143
|
+
*/
|
|
144
|
+
csrf?: boolean | CsrfOptions;
|
|
145
|
+
}
|
|
146
|
+
interface UseSseEventsReturn {
|
|
147
|
+
/**
|
|
148
|
+
* 구독 시작. 이전 스트림이 진행 중이면 중단 후 상태를 초기화한다. 반환 Promise 는 스트림 종료
|
|
149
|
+
* (종료 이벤트/중단/오류/재연결 소진) 시 resolve — 오류는 error 상태로 노출되며 reject 하지 않는다.
|
|
150
|
+
*/
|
|
151
|
+
start: (url: string, init?: RequestInit) => Promise<void>;
|
|
152
|
+
/** 진행 중인 스트림(또는 재연결 대기) 중단. */
|
|
153
|
+
cancel: () => void;
|
|
154
|
+
/** 마지막으로 받은 이벤트 (배열 누적 없음 — 누적은 onEvent 에서). 미수신 시 null. */
|
|
155
|
+
lastEvent: SseEvent | null;
|
|
156
|
+
/** 마지막 이벤트 ID (재연결 `Last-Event-ID` 값). 초기값 `""`. */
|
|
157
|
+
lastEventId: string;
|
|
158
|
+
/** 구독 전 실패 메시지(CommonResponse message 또는 `HTTP <status>`)·전송 오류·onEvent 예외 메시지. */
|
|
159
|
+
error: string | null;
|
|
160
|
+
isStreaming: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* 직전 스트림의 종료 사유 — 'done'(isTerminal 이벤트 또는 HTTP 204) / 'aborted'(cancel·재시작·언마운트) /
|
|
163
|
+
* 'interrupted'(종료 이벤트 없이 끝남 — EOF·전송 오류·구독 전 실패·재연결 소진). 스트리밍 중/시작 전엔 null.
|
|
164
|
+
*/
|
|
165
|
+
finishReason: SseFinishReason | null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 범용 SSE 구독 훅 — @rscc/common-core 의 표준 파서 리더 `readSseEvents` 를 감싼다
|
|
169
|
+
* (contracts/sse-events.md — WHATWG 이벤트 스트림, `event`/`id`/`retry` 필드).
|
|
170
|
+
*
|
|
171
|
+
* - 이벤트마다 `onEvent` 를 부르고 `lastEvent`·`lastEventId` 상태만 갱신한다 (무한 누적 배열 없음).
|
|
172
|
+
* - `isTerminal` 이벤트에서 연결을 닫고 'done', 그 없이 EOF 면 'interrupted'.
|
|
173
|
+
* - reconnect 옵트인 시 `Last-Event-ID` 이어받기 + 서버 `retry:` 존중 (core `retry()` 의 overrideDelay 훅).
|
|
174
|
+
* - 쿠키 인증 모드는 `credentials` + `csrf` (useSse 와 동일 규칙, 호출자 `init` 우선).
|
|
175
|
+
* - 언마운트·재시작 시 진행 중 스트림을 중단한다 (StrictMode 이중 마운트 안전 — useSse 와 같은 구조).
|
|
176
|
+
* - 채팅 프레임(sse-frames.md)은 {@link import("./useSse").useSse} 를 쓴다.
|
|
177
|
+
*/
|
|
178
|
+
declare function useSseEvents(options?: UseSseEventsOptions): UseSseEventsReturn;
|
|
179
|
+
|
|
180
|
+
export { type SseFinishReason, type SseReconnectOptions, type UseSseEventsOptions, type UseSseEventsReturn, type UseSseOptions, type UseSseReturn, useDebounce, useSse, useSseEvents };
|
package/dist/index.js
CHANGED
|
@@ -13,12 +13,13 @@ function useDebounce(value, delayMs = 200) {
|
|
|
13
13
|
import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
|
|
14
14
|
import {
|
|
15
15
|
csrfHeaderFor,
|
|
16
|
+
readSseChatEvents,
|
|
16
17
|
readSseStream,
|
|
17
18
|
retry
|
|
18
19
|
} from "@rscc/common-core";
|
|
19
20
|
var RECONNECT_MARKER = new Error("rscc-sse-reconnect");
|
|
20
21
|
function useSse(options = {}) {
|
|
21
|
-
const { fetchImpl, reconnect, credentials, csrf } = options;
|
|
22
|
+
const { fetchImpl, reconnect, credentials, csrf, parser = "legacy" } = options;
|
|
22
23
|
const [text, setText] = useState2("");
|
|
23
24
|
const [sources, setSources] = useState2(null);
|
|
24
25
|
const [conversationId, setConversationId] = useState2(null);
|
|
@@ -74,7 +75,8 @@ function useSse(options = {}) {
|
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
76
77
|
streamStarted = true;
|
|
77
|
-
|
|
78
|
+
const readChat = parser === "standard" ? readSseChatEvents : readSseStream;
|
|
79
|
+
await readChat(res, {
|
|
78
80
|
onConversationId: setConversationId,
|
|
79
81
|
onSources: setSources,
|
|
80
82
|
onDelta: (d) => setText((prev) => prev + d),
|
|
@@ -133,12 +135,171 @@ function useSse(options = {}) {
|
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
},
|
|
136
|
-
[fetchImpl, reconnect, credentials, csrf]
|
|
138
|
+
[fetchImpl, reconnect, credentials, csrf, parser]
|
|
137
139
|
);
|
|
138
140
|
useEffect2(() => () => abortRef.current?.abort(), []);
|
|
139
141
|
return { start, cancel, text, sources, conversationId, error, isStreaming, finishReason };
|
|
140
142
|
}
|
|
143
|
+
|
|
144
|
+
// src/useSseEvents.ts
|
|
145
|
+
import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
|
|
146
|
+
import {
|
|
147
|
+
csrfHeaderFor as csrfHeaderFor2,
|
|
148
|
+
readSseEvents,
|
|
149
|
+
retry as retry2
|
|
150
|
+
} from "@rscc/common-core";
|
|
151
|
+
var RECONNECT_MARKER2 = new Error("rscc-sse-events-reconnect");
|
|
152
|
+
var LAST_EVENT_ID_HEADER = "Last-Event-ID";
|
|
153
|
+
var messageOf = (err) => err instanceof Error ? err.message : String(err);
|
|
154
|
+
function useSseEvents(options = {}) {
|
|
155
|
+
const [lastEvent, setLastEvent] = useState3(null);
|
|
156
|
+
const [lastEventId, setLastEventId] = useState3("");
|
|
157
|
+
const [error, setError] = useState3(null);
|
|
158
|
+
const [isStreaming, setIsStreaming] = useState3(false);
|
|
159
|
+
const [finishReason, setFinishReason] = useState3(null);
|
|
160
|
+
const abortRef = useRef2(null);
|
|
161
|
+
const optionsRef = useRef2(options);
|
|
162
|
+
useEffect3(() => {
|
|
163
|
+
optionsRef.current = options;
|
|
164
|
+
});
|
|
165
|
+
const cancel = useCallback2(() => {
|
|
166
|
+
abortRef.current?.abort();
|
|
167
|
+
}, []);
|
|
168
|
+
const start = useCallback2(async (url, init) => {
|
|
169
|
+
abortRef.current?.abort();
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
abortRef.current = controller;
|
|
172
|
+
const { fetchImpl, reconnect, credentials, csrf } = optionsRef.current;
|
|
173
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
174
|
+
setLastEvent(null);
|
|
175
|
+
setLastEventId("");
|
|
176
|
+
setError(null);
|
|
177
|
+
setFinishReason(null);
|
|
178
|
+
setIsStreaming(true);
|
|
179
|
+
let currentLastEventId = "";
|
|
180
|
+
let serverRetryMs = null;
|
|
181
|
+
let reason = "interrupted";
|
|
182
|
+
const requestInit = () => {
|
|
183
|
+
const merged = { ...init, signal: controller.signal };
|
|
184
|
+
const effectiveCredentials = init?.credentials ?? credentials;
|
|
185
|
+
if (effectiveCredentials !== void 0) merged.credentials = effectiveCredentials;
|
|
186
|
+
let headers = null;
|
|
187
|
+
const ensureHeaders = () => {
|
|
188
|
+
if (headers === null) headers = new Headers(init?.headers);
|
|
189
|
+
return headers;
|
|
190
|
+
};
|
|
191
|
+
if (csrf) {
|
|
192
|
+
const csrfHeader = csrfHeaderFor2(url, init?.method, csrf);
|
|
193
|
+
if (csrfHeader && !ensureHeaders().has(csrfHeader[0])) {
|
|
194
|
+
ensureHeaders().set(csrfHeader[0], csrfHeader[1]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (currentLastEventId !== "") ensureHeaders().set(LAST_EVENT_ID_HEADER, currentLastEventId);
|
|
198
|
+
if (headers !== null) merged.headers = headers;
|
|
199
|
+
return merged;
|
|
200
|
+
};
|
|
201
|
+
const attemptStream = async (attempt) => {
|
|
202
|
+
if (attempt > 0) setError(null);
|
|
203
|
+
let terminal = false;
|
|
204
|
+
let streamStarted = false;
|
|
205
|
+
let callbackFailed = false;
|
|
206
|
+
let callbackError;
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetchFn(url, requestInit());
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
const json = await res.json().catch(() => null);
|
|
211
|
+
setError(typeof json?.message === "string" ? json.message : `HTTP ${res.status}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (res.status === 204) {
|
|
215
|
+
reason = "done";
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
streamStarted = true;
|
|
219
|
+
const result = await readSseEvents(res, {
|
|
220
|
+
// 연결을 넘어 마지막 이벤트 ID 유지 (EventSource 동일) — id 없는 이벤트도 이어받는다.
|
|
221
|
+
lastEventId: currentLastEventId,
|
|
222
|
+
onEvent: (event) => {
|
|
223
|
+
currentLastEventId = event.id;
|
|
224
|
+
setLastEvent(event);
|
|
225
|
+
setLastEventId(event.id);
|
|
226
|
+
try {
|
|
227
|
+
optionsRef.current.onEvent?.(event);
|
|
228
|
+
if (optionsRef.current.isTerminal?.(event) === true) {
|
|
229
|
+
terminal = true;
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
callbackFailed = true;
|
|
234
|
+
callbackError = err;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
return void 0;
|
|
238
|
+
},
|
|
239
|
+
onRetry: (ms) => {
|
|
240
|
+
serverRetryMs = ms;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
currentLastEventId = result.lastEventId;
|
|
244
|
+
setLastEventId(result.lastEventId);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (controller.signal.aborted) {
|
|
247
|
+
reason = "aborted";
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
setError(messageOf(err));
|
|
251
|
+
if (streamStarted && reconnect) throw RECONNECT_MARKER2;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (controller.signal.aborted) {
|
|
255
|
+
reason = "aborted";
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (callbackFailed) {
|
|
259
|
+
setError(messageOf(callbackError));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (terminal) {
|
|
263
|
+
reason = "done";
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (reconnect) throw RECONNECT_MARKER2;
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
if (reconnect) {
|
|
270
|
+
await retry2(attemptStream, {
|
|
271
|
+
retries: reconnect.retries ?? 3,
|
|
272
|
+
minDelayMs: reconnect.minDelayMs ?? 1e3,
|
|
273
|
+
maxDelayMs: reconnect.maxDelayMs ?? 15e3,
|
|
274
|
+
factor: reconnect.factor ?? 2,
|
|
275
|
+
jitter: reconnect.jitter ?? true,
|
|
276
|
+
shouldRetry: (e) => e === RECONNECT_MARKER2,
|
|
277
|
+
// 서버 retry: 힌트가 있으면 그 값을 재연결 대기로 (WHATWG reconnection time).
|
|
278
|
+
overrideDelay: (_e, _attempt, delayMs) => serverRetryMs ?? delayMs,
|
|
279
|
+
signal: controller.signal
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
await attemptStream(0);
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (controller.signal.aborted) {
|
|
286
|
+
reason = "aborted";
|
|
287
|
+
} else if (err !== RECONNECT_MARKER2) {
|
|
288
|
+
setError(messageOf(err));
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
if (abortRef.current === controller) {
|
|
292
|
+
abortRef.current = null;
|
|
293
|
+
setIsStreaming(false);
|
|
294
|
+
setFinishReason(reason);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}, []);
|
|
298
|
+
useEffect3(() => () => abortRef.current?.abort(), []);
|
|
299
|
+
return { start, cancel, lastEvent, lastEventId, error, isStreaming, finishReason };
|
|
300
|
+
}
|
|
141
301
|
export {
|
|
142
302
|
useDebounce,
|
|
143
|
-
useSse
|
|
303
|
+
useSse,
|
|
304
|
+
useSseEvents
|
|
144
305
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rscc/common-react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "RSCC 공통 React 훅 — useDebounce, useSse (@rscc/common-core
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "RSCC 공통 React 훅 — useDebounce, useSse(채팅 SSE), useSseEvents(범용 SSE — Last-Event-ID 재연결) (@rscc/common-core 래핑).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rscc",
|
|
7
7
|
"react",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"prepublishOnly": "npm run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@rscc/common-core": "^0.
|
|
57
|
+
"@rscc/common-core": "^0.5.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"react": ">=18"
|