@rscc/common-react 0.3.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 +78 -5
- package/dist/index.cjs +179 -6
- package/dist/index.d.cts +100 -2
- package/dist/index.d.ts +100 -2
- package/dist/index.js +187 -6
- package/package.json +4 -4
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
|
|
|
@@ -10,14 +11,19 @@ npm i @rscc/common-react
|
|
|
10
11
|
|
|
11
12
|
- **peerDependencies: `react >= 18`** — 소비 앱이 직접 설치
|
|
12
13
|
- `@rscc/common-core` 는 `^` 범위 의존으로 함께 설치된다 (두 패키지는 락스텝 버전)
|
|
13
|
-
- **Node >=
|
|
14
|
+
- **Node >= 20**, ESM + CJS 듀얼 빌드, 타입 선언 동봉
|
|
14
15
|
|
|
15
16
|
## 훅
|
|
16
17
|
|
|
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` |
|
|
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:` 존중) |
|
|
23
|
+
|
|
24
|
+
`UseSseOptions`: `fetchImpl?` · `reconnect?: SseReconnectOptions` · `credentials?: RequestCredentials` · `csrf?: boolean | CsrfOptions`
|
|
25
|
+
· `parser?: "legacy" | "standard"` (기본 `"legacy"`). `UseSseEventsOptions`: `fetchImpl?` · `onEvent?(e)` · `isTerminal?(e)` ·
|
|
26
|
+
`reconnect?` · `credentials?` · `csrf?` (`CsrfOptions`·`SseEvent` 는 `@rscc/common-core` 에서 export).
|
|
21
27
|
|
|
22
28
|
## 사용 예시
|
|
23
29
|
|
|
@@ -65,10 +71,77 @@ const sse = useSse({ reconnect: { retries: 3, minDelayMs: 1000, maxDelayMs: 1500
|
|
|
65
71
|
- 재연결 트리거는 **"스트림 시작(`res.ok`) 후 `[DONE]` 없이 끊긴 경우"뿐** — `'done'`/`'aborted'`/
|
|
66
72
|
구독 전 실패(`!res.ok`)/in-band error 프레임 종료는 서버 의도로 보고 재연결하지 않는다.
|
|
67
73
|
- 각 시도는 **전체 재요청**이며 직전 시도의 `text`/`sources`/`conversationId`/`error` 를 초기화한다
|
|
68
|
-
(중복 누적 방지). 이어받기(Last-Event-ID)는 지원하지
|
|
74
|
+
(중복 누적 방지). 이어받기(Last-Event-ID)는 지원하지 않는다 — 범용 `useSseEvents` 는 지원한다.
|
|
69
75
|
- [주의] 비멱등 POST 스트림은 전체 재요청이 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것.
|
|
70
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
|
+
|
|
116
|
+
### useSse — 쿠키 인증 모드 (credentials / CSRF)
|
|
117
|
+
|
|
118
|
+
서버가 인증 자격을 HttpOnly 쿠키(`jwt-cookie`) 또는 서버 세션(`session`)으로 받는다면 `credentials` 와 `csrf` 를 켠다.
|
|
119
|
+
채팅 스트림은 보통 POST 라 CSRF 헤더가 필요하다. 서버가 `bearer`(기본)면 지금처럼 `init.headers` 에
|
|
120
|
+
`Authorization: Bearer …` 를 직접 싣는다.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// 같은 출처 (리버스 프록시로 프론트와 API 를 한 오리진에 — 권장)
|
|
124
|
+
const sse = useSse({ credentials: "same-origin", csrf: true });
|
|
125
|
+
await sse.start("/api/v1/chat/stream", { method: "POST", headers, body }); // X-XSRF-TOKEN 자동 부착
|
|
126
|
+
|
|
127
|
+
// 같은 사이트 서브도메인 (app.example.com → api.example.com)
|
|
128
|
+
const sse2 = useSse({
|
|
129
|
+
credentials: "include",
|
|
130
|
+
csrf: { allowedOrigins: ["https://api.example.com"] },
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **`credentials`**: 미지정이면 fetch 에 속성 자체를 넘기지 않는다(현행 동작). `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
135
|
+
- **`csrf`** (`true` | `CsrfOptions`): 쿠키 `XSRF-TOKEN` 의 값을 `X-XSRF-TOKEN` 헤더로 싣는다 — **비안전 메서드**
|
|
136
|
+
(GET·HEAD·OPTIONS·TRACE 외)이고 쿠키가 있으며 URL 이 **같은 출처**(상대 URL 포함)이거나 `allowedOrigins` 에 속할 때만.
|
|
137
|
+
교차 출처로는 보내지 않는다. `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않고, 재연결 시도마다 쿠키를 다시 읽는다.
|
|
138
|
+
`CsrfOptions` 의 `cookieName`·`headerName`·`readCookie`(React Native·테스트용 원시 쿠키 문자열 공급자)로 바꿀 수 있다.
|
|
139
|
+
- SSR(`document` 없음)에서는 아무것도 붙이지 않고 오류도 내지 않는다.
|
|
140
|
+
- 쿠키 모드에서는 **토큰을 localStorage 등에 저장하지 않는다** — 로그인 상태는 토큰 만료 계산(`isTokenExpired`) 대신
|
|
141
|
+
`/me` 같은 서버 엔드포인트로 확인한다.
|
|
142
|
+
- 일반 API 호출은 `@rscc/common-core` 의 `createApiClient({ credentials, csrf })` 로 같은 규칙을 쓴다.
|
|
143
|
+
|
|
71
144
|
## 관련 패키지
|
|
72
145
|
|
|
73
|
-
- 코어(SSE
|
|
146
|
+
- 코어(SSE `readSseStream`·`readSseChatEvents`·`readSseEvents`·apiClient 등): [@rscc/common-core](https://www.npmjs.com/package/@rscc/common-core)
|
|
74
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 } = 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);
|
|
@@ -67,6 +68,22 @@ function useSse(options = {}) {
|
|
|
67
68
|
setFinishReason(null);
|
|
68
69
|
setIsStreaming(true);
|
|
69
70
|
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
71
|
+
const requestInit = () => {
|
|
72
|
+
const merged = { ...init, signal: controller.signal };
|
|
73
|
+
const effectiveCredentials = init?.credentials ?? credentials;
|
|
74
|
+
if (effectiveCredentials !== void 0) merged.credentials = effectiveCredentials;
|
|
75
|
+
if (csrf) {
|
|
76
|
+
const csrfHeader = (0, import_common_core.csrfHeaderFor)(url, init?.method, csrf);
|
|
77
|
+
if (csrfHeader) {
|
|
78
|
+
const headers = new Headers(init?.headers);
|
|
79
|
+
if (!headers.has(csrfHeader[0])) {
|
|
80
|
+
headers.set(csrfHeader[0], csrfHeader[1]);
|
|
81
|
+
merged.headers = headers;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return merged;
|
|
86
|
+
};
|
|
70
87
|
let reason = "interrupted";
|
|
71
88
|
const attemptStream = async (attempt) => {
|
|
72
89
|
if (attempt > 0) resetStreamState();
|
|
@@ -74,14 +91,15 @@ function useSse(options = {}) {
|
|
|
74
91
|
let sawErrorFrame = false;
|
|
75
92
|
let streamStarted = false;
|
|
76
93
|
try {
|
|
77
|
-
const res = await fetchFn(url,
|
|
94
|
+
const res = await fetchFn(url, requestInit());
|
|
78
95
|
if (!res.ok) {
|
|
79
96
|
const json = await res.json().catch(() => null);
|
|
80
97
|
setError(typeof json?.message === "string" ? json.message : `HTTP ${res.status}`);
|
|
81
98
|
return;
|
|
82
99
|
}
|
|
83
100
|
streamStarted = true;
|
|
84
|
-
|
|
101
|
+
const readChat = parser === "standard" ? import_common_core.readSseChatEvents : import_common_core.readSseStream;
|
|
102
|
+
await readChat(res, {
|
|
85
103
|
onConversationId: setConversationId,
|
|
86
104
|
onSources: setSources,
|
|
87
105
|
onDelta: (d) => setText((prev) => prev + d),
|
|
@@ -140,13 +158,168 @@ function useSse(options = {}) {
|
|
|
140
158
|
}
|
|
141
159
|
}
|
|
142
160
|
},
|
|
143
|
-
[fetchImpl, reconnect]
|
|
161
|
+
[fetchImpl, reconnect, credentials, csrf, parser]
|
|
144
162
|
);
|
|
145
163
|
(0, import_react2.useEffect)(() => () => abortRef.current?.abort(), []);
|
|
146
164
|
return { start, cancel, text, sources, conversationId, error, isStreaming, finishReason };
|
|
147
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
|
+
}
|
|
148
320
|
// Annotate the CommonJS export names for ESM import in node:
|
|
149
321
|
0 && (module.exports = {
|
|
150
322
|
useDebounce,
|
|
151
|
-
useSse
|
|
323
|
+
useSse,
|
|
324
|
+
useSseEvents
|
|
152
325
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { 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. */
|
|
@@ -46,6 +50,28 @@ interface UseSseOptions {
|
|
|
46
50
|
* 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것).
|
|
47
51
|
*/
|
|
48
52
|
reconnect?: SseReconnectOptions;
|
|
53
|
+
/**
|
|
54
|
+
* fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
|
|
55
|
+
* 같은 출처 `"same-origin"`(fetch 기본값), 교차 출처(서브도메인 API) `"include"`.
|
|
56
|
+
* 미지정 = 속성 자체를 넘기지 않음 (현행 동작). `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
57
|
+
*/
|
|
58
|
+
credentials?: RequestCredentials;
|
|
59
|
+
/**
|
|
60
|
+
* CSRF double-submit 헤더 자동 부착 (core `csrfHeaderFor` — 골든 벡터 XC-01~08). 채팅 스트림은 보통
|
|
61
|
+
* POST 라 쿠키 모드에서 필요하다. `true` = 기본값(쿠키 `XSRF-TOKEN` → 헤더 `X-XSRF-TOKEN`, 같은 출처만),
|
|
62
|
+
* 객체 = `CsrfOptions`(`allowedOrigins`·`readCookie` 등). 미지정/false = 부착 안 함 (현행 동작).
|
|
63
|
+
*
|
|
64
|
+
* 비안전 메서드 + CSRF 쿠키 존재 + 같은 출처(상대 URL 포함) 또는 `allowedOrigins` 일 때만 붙이고,
|
|
65
|
+
* `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않는다. 재연결 시도마다 쿠키를 다시 읽는다.
|
|
66
|
+
*/
|
|
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";
|
|
49
75
|
}
|
|
50
76
|
interface UseSseReturn {
|
|
51
77
|
/**
|
|
@@ -69,14 +95,86 @@ interface UseSseReturn {
|
|
|
69
95
|
}
|
|
70
96
|
/**
|
|
71
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} 를 쓴다.
|
|
72
100
|
*
|
|
73
101
|
* - 구독 전 검증 실패(!res.ok — CommonResponse JSON 에러 경로)는 봉투의 message 를 error 로 노출.
|
|
74
102
|
* - 스트림 시작 후 오류는 in-band error 프레임으로 수신되어 동일하게 error 로 노출.
|
|
75
103
|
* - 언마운트 시 진행 중 스트림을 자동 중단한다.
|
|
104
|
+
* - 쿠키 인증 모드(contracts/session-auth.md)는 `credentials` + `csrf` 옵션으로 — 호출자 `init` 이 우선한다.
|
|
76
105
|
* - 종료 사유는 finishReason 으로 구분한다 — 'done'([DONE] 수신) / 'aborted'(중단) /
|
|
77
106
|
* 'interrupted'([DONE] 없이 끊김). reconnect 옵트인 시 'interrupted' 성 종료 중
|
|
78
107
|
* "스트림 시작 후 끊김"만 지수 백오프로 전체 재요청한다 ({@link SseReconnectOptions}).
|
|
79
108
|
*/
|
|
80
109
|
declare function useSse(options?: UseSseOptions): UseSseReturn;
|
|
81
110
|
|
|
82
|
-
|
|
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 { 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. */
|
|
@@ -46,6 +50,28 @@ interface UseSseOptions {
|
|
|
46
50
|
* 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것).
|
|
47
51
|
*/
|
|
48
52
|
reconnect?: SseReconnectOptions;
|
|
53
|
+
/**
|
|
54
|
+
* fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
|
|
55
|
+
* 같은 출처 `"same-origin"`(fetch 기본값), 교차 출처(서브도메인 API) `"include"`.
|
|
56
|
+
* 미지정 = 속성 자체를 넘기지 않음 (현행 동작). `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
57
|
+
*/
|
|
58
|
+
credentials?: RequestCredentials;
|
|
59
|
+
/**
|
|
60
|
+
* CSRF double-submit 헤더 자동 부착 (core `csrfHeaderFor` — 골든 벡터 XC-01~08). 채팅 스트림은 보통
|
|
61
|
+
* POST 라 쿠키 모드에서 필요하다. `true` = 기본값(쿠키 `XSRF-TOKEN` → 헤더 `X-XSRF-TOKEN`, 같은 출처만),
|
|
62
|
+
* 객체 = `CsrfOptions`(`allowedOrigins`·`readCookie` 등). 미지정/false = 부착 안 함 (현행 동작).
|
|
63
|
+
*
|
|
64
|
+
* 비안전 메서드 + CSRF 쿠키 존재 + 같은 출처(상대 URL 포함) 또는 `allowedOrigins` 일 때만 붙이고,
|
|
65
|
+
* `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않는다. 재연결 시도마다 쿠키를 다시 읽는다.
|
|
66
|
+
*/
|
|
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";
|
|
49
75
|
}
|
|
50
76
|
interface UseSseReturn {
|
|
51
77
|
/**
|
|
@@ -69,14 +95,86 @@ interface UseSseReturn {
|
|
|
69
95
|
}
|
|
70
96
|
/**
|
|
71
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} 를 쓴다.
|
|
72
100
|
*
|
|
73
101
|
* - 구독 전 검증 실패(!res.ok — CommonResponse JSON 에러 경로)는 봉투의 message 를 error 로 노출.
|
|
74
102
|
* - 스트림 시작 후 오류는 in-band error 프레임으로 수신되어 동일하게 error 로 노출.
|
|
75
103
|
* - 언마운트 시 진행 중 스트림을 자동 중단한다.
|
|
104
|
+
* - 쿠키 인증 모드(contracts/session-auth.md)는 `credentials` + `csrf` 옵션으로 — 호출자 `init` 이 우선한다.
|
|
76
105
|
* - 종료 사유는 finishReason 으로 구분한다 — 'done'([DONE] 수신) / 'aborted'(중단) /
|
|
77
106
|
* 'interrupted'([DONE] 없이 끊김). reconnect 옵트인 시 'interrupted' 성 종료 중
|
|
78
107
|
* "스트림 시작 후 끊김"만 지수 백오프로 전체 재요청한다 ({@link SseReconnectOptions}).
|
|
79
108
|
*/
|
|
80
109
|
declare function useSse(options?: UseSseOptions): UseSseReturn;
|
|
81
110
|
|
|
82
|
-
|
|
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
|
@@ -11,10 +11,15 @@ function useDebounce(value, delayMs = 200) {
|
|
|
11
11
|
|
|
12
12
|
// src/useSse.ts
|
|
13
13
|
import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
csrfHeaderFor,
|
|
16
|
+
readSseChatEvents,
|
|
17
|
+
readSseStream,
|
|
18
|
+
retry
|
|
19
|
+
} from "@rscc/common-core";
|
|
15
20
|
var RECONNECT_MARKER = new Error("rscc-sse-reconnect");
|
|
16
21
|
function useSse(options = {}) {
|
|
17
|
-
const { fetchImpl, reconnect } = options;
|
|
22
|
+
const { fetchImpl, reconnect, credentials, csrf, parser = "legacy" } = options;
|
|
18
23
|
const [text, setText] = useState2("");
|
|
19
24
|
const [sources, setSources] = useState2(null);
|
|
20
25
|
const [conversationId, setConversationId] = useState2(null);
|
|
@@ -40,6 +45,22 @@ function useSse(options = {}) {
|
|
|
40
45
|
setFinishReason(null);
|
|
41
46
|
setIsStreaming(true);
|
|
42
47
|
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
48
|
+
const requestInit = () => {
|
|
49
|
+
const merged = { ...init, signal: controller.signal };
|
|
50
|
+
const effectiveCredentials = init?.credentials ?? credentials;
|
|
51
|
+
if (effectiveCredentials !== void 0) merged.credentials = effectiveCredentials;
|
|
52
|
+
if (csrf) {
|
|
53
|
+
const csrfHeader = csrfHeaderFor(url, init?.method, csrf);
|
|
54
|
+
if (csrfHeader) {
|
|
55
|
+
const headers = new Headers(init?.headers);
|
|
56
|
+
if (!headers.has(csrfHeader[0])) {
|
|
57
|
+
headers.set(csrfHeader[0], csrfHeader[1]);
|
|
58
|
+
merged.headers = headers;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return merged;
|
|
63
|
+
};
|
|
43
64
|
let reason = "interrupted";
|
|
44
65
|
const attemptStream = async (attempt) => {
|
|
45
66
|
if (attempt > 0) resetStreamState();
|
|
@@ -47,14 +68,15 @@ function useSse(options = {}) {
|
|
|
47
68
|
let sawErrorFrame = false;
|
|
48
69
|
let streamStarted = false;
|
|
49
70
|
try {
|
|
50
|
-
const res = await fetchFn(url,
|
|
71
|
+
const res = await fetchFn(url, requestInit());
|
|
51
72
|
if (!res.ok) {
|
|
52
73
|
const json = await res.json().catch(() => null);
|
|
53
74
|
setError(typeof json?.message === "string" ? json.message : `HTTP ${res.status}`);
|
|
54
75
|
return;
|
|
55
76
|
}
|
|
56
77
|
streamStarted = true;
|
|
57
|
-
|
|
78
|
+
const readChat = parser === "standard" ? readSseChatEvents : readSseStream;
|
|
79
|
+
await readChat(res, {
|
|
58
80
|
onConversationId: setConversationId,
|
|
59
81
|
onSources: setSources,
|
|
60
82
|
onDelta: (d) => setText((prev) => prev + d),
|
|
@@ -113,12 +135,171 @@ function useSse(options = {}) {
|
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
},
|
|
116
|
-
[fetchImpl, reconnect]
|
|
138
|
+
[fetchImpl, reconnect, credentials, csrf, parser]
|
|
117
139
|
);
|
|
118
140
|
useEffect2(() => () => abortRef.current?.abort(), []);
|
|
119
141
|
return { start, cancel, text, sources, conversationId, error, isStreaming, finishReason };
|
|
120
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
|
+
}
|
|
121
301
|
export {
|
|
122
302
|
useDebounce,
|
|
123
|
-
useSse
|
|
303
|
+
useSse,
|
|
304
|
+
useSseEvents
|
|
124
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",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"url": "https://github.com/Jeonghyeon-Ryu/r-common/issues"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=20"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
@@ -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"
|