@rscc/common-react 0.2.0 → 0.4.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 +60 -8
- package/dist/index.cjs +19 -3
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +24 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,14 +10,17 @@ npm i @rscc/common-react
|
|
|
10
10
|
|
|
11
11
|
- **peerDependencies: `react >= 18`** — 소비 앱이 직접 설치
|
|
12
12
|
- `@rscc/common-core` 는 `^` 범위 의존으로 함께 설치된다 (두 패키지는 락스텝 버전)
|
|
13
|
-
- **Node >=
|
|
13
|
+
- **Node >= 20**, ESM + CJS 듀얼 빌드, 타입 선언 동봉
|
|
14
14
|
|
|
15
15
|
## 훅
|
|
16
16
|
|
|
17
17
|
| 훅 | 시그니처 | 설명 |
|
|
18
18
|
|---|---|---|
|
|
19
19
|
| `useDebounce` | `useDebounce<T>(value, delayMs = 200): T` | 마지막 안정 값만 통과 — 자동완성 API 호출 절감 |
|
|
20
|
-
| `useSse` | `useSse(options?)` → `{ start, cancel, text, sources, conversationId, error, isStreaming }` | SSE 구독 상태 훅 — 언마운트 시 자동
|
|
20
|
+
| `useSse` | `useSse(options?)` → `{ start, cancel, text, sources, conversationId, error, isStreaming, finishReason }` | SSE 구독 상태 훅 — 언마운트 시 자동 중단, `finishReason` 으로 종료 사유 구분, opt-in `reconnect`, 쿠키 인증 모드용 `credentials`·`csrf` |
|
|
21
|
+
|
|
22
|
+
`UseSseOptions`: `fetchImpl?` · `reconnect?: SseReconnectOptions` · `credentials?: RequestCredentials` · `csrf?: boolean | CsrfOptions`
|
|
23
|
+
(`CsrfOptions` 는 `@rscc/common-core` 에서 export).
|
|
21
24
|
|
|
22
25
|
## 사용 예시
|
|
23
26
|
|
|
@@ -35,19 +38,68 @@ const debounced = useDebounce(keyword, 200);
|
|
|
35
38
|
```ts
|
|
36
39
|
import { useSse } from "@rscc/common-react";
|
|
37
40
|
|
|
38
|
-
const { start, cancel, text, sources, conversationId, error, isStreaming } = useSse();
|
|
41
|
+
const { start, cancel, text, sources, conversationId, error, isStreaming, finishReason } = useSse();
|
|
39
42
|
|
|
40
43
|
await start("/api/v1/chat/stream", { method: "POST", headers, body });
|
|
41
44
|
// text: delta 누적 텍스트, sources: RAG 근거, error: in-band 오류 메시지
|
|
42
45
|
// cancel(): 중단 — 그때까지의 text 는 보존되고 error 는 남지 않는다
|
|
46
|
+
// finishReason: 직전 스트림의 종료 사유 — 스트리밍 중/시작 전엔 null
|
|
43
47
|
```
|
|
44
48
|
|
|
45
49
|
- 구독 전 검증 실패(`!res.ok`)는 응답 봉투의 `message` 를 `error` 로 노출한다.
|
|
46
50
|
- `start` 재호출 시 이전 스트림을 중단하고 상태를 초기화한다.
|
|
47
|
-
-
|
|
51
|
+
- 반환 Promise 는 스트림 종료 시 resolve 한다 — 오류는 `error` 상태로 노출되며 reject 하지 않는다.
|
|
52
|
+
|
|
53
|
+
`finishReason` (`SseFinishReason`) 으로 정상 종료와 끊김을 구분한다:
|
|
54
|
+
|
|
55
|
+
| 값 | 의미 |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `'done'` | `[DONE]` 종료 마커 수신 (in-band error 프레임 뒤의 `[DONE]` 포함 — `error` 와 공존) |
|
|
58
|
+
| `'aborted'` | `cancel()` / 새 `start()` 로 대체 / 언마운트로 인한 중단 |
|
|
59
|
+
| `'interrupted'` | `[DONE]` 없이 끝난 비정상 종료 — 무언 EOF, 전송 오류, 구독 전 검증 실패(`!res.ok`) 포함 |
|
|
60
|
+
|
|
61
|
+
### useSse — 자동 재연결 (opt-in, 기본 off)
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const sse = useSse({ reconnect: { retries: 3, minDelayMs: 1000, maxDelayMs: 15000 } });
|
|
65
|
+
// 기본값: retries 3 · minDelayMs 1000 · maxDelayMs 15000 · factor 2 · jitter true (SseReconnectOptions)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- 재연결 트리거는 **"스트림 시작(`res.ok`) 후 `[DONE]` 없이 끊긴 경우"뿐** — `'done'`/`'aborted'`/
|
|
69
|
+
구독 전 실패(`!res.ok`)/in-band error 프레임 종료는 서버 의도로 보고 재연결하지 않는다.
|
|
70
|
+
- 각 시도는 **전체 재요청**이며 직전 시도의 `text`/`sources`/`conversationId`/`error` 를 초기화한다
|
|
71
|
+
(중복 누적 방지). 이어받기(Last-Event-ID)는 지원하지 않는다.
|
|
72
|
+
- [주의] 비멱등 POST 스트림은 전체 재요청이 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것.
|
|
73
|
+
|
|
74
|
+
### useSse — 쿠키 인증 모드 (credentials / CSRF)
|
|
75
|
+
|
|
76
|
+
서버가 인증 자격을 HttpOnly 쿠키(`jwt-cookie`) 또는 서버 세션(`session`)으로 받는다면 `credentials` 와 `csrf` 를 켠다.
|
|
77
|
+
채팅 스트림은 보통 POST 라 CSRF 헤더가 필요하다. 서버가 `bearer`(기본)면 지금처럼 `init.headers` 에
|
|
78
|
+
`Authorization: Bearer …` 를 직접 싣는다.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// 같은 출처 (리버스 프록시로 프론트와 API 를 한 오리진에 — 권장)
|
|
82
|
+
const sse = useSse({ credentials: "same-origin", csrf: true });
|
|
83
|
+
await sse.start("/api/v1/chat/stream", { method: "POST", headers, body }); // X-XSRF-TOKEN 자동 부착
|
|
84
|
+
|
|
85
|
+
// 같은 사이트 서브도메인 (app.example.com → api.example.com)
|
|
86
|
+
const sse2 = useSse({
|
|
87
|
+
credentials: "include",
|
|
88
|
+
csrf: { allowedOrigins: ["https://api.example.com"] },
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **`credentials`**: 미지정이면 fetch 에 속성 자체를 넘기지 않는다(현행 동작). `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
93
|
+
- **`csrf`** (`true` | `CsrfOptions`): 쿠키 `XSRF-TOKEN` 의 값을 `X-XSRF-TOKEN` 헤더로 싣는다 — **비안전 메서드**
|
|
94
|
+
(GET·HEAD·OPTIONS·TRACE 외)이고 쿠키가 있으며 URL 이 **같은 출처**(상대 URL 포함)이거나 `allowedOrigins` 에 속할 때만.
|
|
95
|
+
교차 출처로는 보내지 않는다. `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않고, 재연결 시도마다 쿠키를 다시 읽는다.
|
|
96
|
+
`CsrfOptions` 의 `cookieName`·`headerName`·`readCookie`(React Native·테스트용 원시 쿠키 문자열 공급자)로 바꿀 수 있다.
|
|
97
|
+
- SSR(`document` 없음)에서는 아무것도 붙이지 않고 오류도 내지 않는다.
|
|
98
|
+
- 쿠키 모드에서는 **토큰을 localStorage 등에 저장하지 않는다** — 로그인 상태는 토큰 만료 계산(`isTokenExpired`) 대신
|
|
99
|
+
`/me` 같은 서버 엔드포인트로 확인한다.
|
|
100
|
+
- 일반 API 호출은 `@rscc/common-core` 의 `createApiClient({ credentials, csrf })` 로 같은 규칙을 쓴다.
|
|
48
101
|
|
|
49
|
-
##
|
|
102
|
+
## 관련 패키지
|
|
50
103
|
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
- 저장소: [Jeonghyeon-Ryu/r-common](https://github.com/Jeonghyeon-Ryu/r-common) · MIT
|
|
104
|
+
- 코어(SSE 파서 `readSseStream`·apiClient 등): [@rscc/common-core](https://www.npmjs.com/package/@rscc/common-core)
|
|
105
|
+
- 라이선스: MIT
|
package/dist/index.cjs
CHANGED
|
@@ -41,7 +41,7 @@ var import_react2 = require("react");
|
|
|
41
41
|
var import_common_core = require("@rscc/common-core");
|
|
42
42
|
var RECONNECT_MARKER = new Error("rscc-sse-reconnect");
|
|
43
43
|
function useSse(options = {}) {
|
|
44
|
-
const { fetchImpl, reconnect } = options;
|
|
44
|
+
const { fetchImpl, reconnect, credentials, csrf } = options;
|
|
45
45
|
const [text, setText] = (0, import_react2.useState)("");
|
|
46
46
|
const [sources, setSources] = (0, import_react2.useState)(null);
|
|
47
47
|
const [conversationId, setConversationId] = (0, import_react2.useState)(null);
|
|
@@ -67,6 +67,22 @@ function useSse(options = {}) {
|
|
|
67
67
|
setFinishReason(null);
|
|
68
68
|
setIsStreaming(true);
|
|
69
69
|
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
70
|
+
const requestInit = () => {
|
|
71
|
+
const merged = { ...init, signal: controller.signal };
|
|
72
|
+
const effectiveCredentials = init?.credentials ?? credentials;
|
|
73
|
+
if (effectiveCredentials !== void 0) merged.credentials = effectiveCredentials;
|
|
74
|
+
if (csrf) {
|
|
75
|
+
const csrfHeader = (0, import_common_core.csrfHeaderFor)(url, init?.method, csrf);
|
|
76
|
+
if (csrfHeader) {
|
|
77
|
+
const headers = new Headers(init?.headers);
|
|
78
|
+
if (!headers.has(csrfHeader[0])) {
|
|
79
|
+
headers.set(csrfHeader[0], csrfHeader[1]);
|
|
80
|
+
merged.headers = headers;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return merged;
|
|
85
|
+
};
|
|
70
86
|
let reason = "interrupted";
|
|
71
87
|
const attemptStream = async (attempt) => {
|
|
72
88
|
if (attempt > 0) resetStreamState();
|
|
@@ -74,7 +90,7 @@ function useSse(options = {}) {
|
|
|
74
90
|
let sawErrorFrame = false;
|
|
75
91
|
let streamStarted = false;
|
|
76
92
|
try {
|
|
77
|
-
const res = await fetchFn(url,
|
|
93
|
+
const res = await fetchFn(url, requestInit());
|
|
78
94
|
if (!res.ok) {
|
|
79
95
|
const json = await res.json().catch(() => null);
|
|
80
96
|
setError(typeof json?.message === "string" ? json.message : `HTTP ${res.status}`);
|
|
@@ -140,7 +156,7 @@ function useSse(options = {}) {
|
|
|
140
156
|
}
|
|
141
157
|
}
|
|
142
158
|
},
|
|
143
|
-
[fetchImpl, reconnect]
|
|
159
|
+
[fetchImpl, reconnect, credentials, csrf]
|
|
144
160
|
);
|
|
145
161
|
(0, import_react2.useEffect)(() => () => abortRef.current?.abort(), []);
|
|
146
162
|
return { start, cancel, text, sources, conversationId, error, isStreaming, finishReason };
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SseSource } from '@rscc/common-core';
|
|
1
|
+
import { CsrfOptions, SseSource } from '@rscc/common-core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* value 가 delayMs(ms) 동안 더 이상 바뀌지 않으면 그 값을 반환한다.
|
|
@@ -46,6 +46,21 @@ interface UseSseOptions {
|
|
|
46
46
|
* 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것).
|
|
47
47
|
*/
|
|
48
48
|
reconnect?: SseReconnectOptions;
|
|
49
|
+
/**
|
|
50
|
+
* fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
|
|
51
|
+
* 같은 출처 `"same-origin"`(fetch 기본값), 교차 출처(서브도메인 API) `"include"`.
|
|
52
|
+
* 미지정 = 속성 자체를 넘기지 않음 (현행 동작). `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
53
|
+
*/
|
|
54
|
+
credentials?: RequestCredentials;
|
|
55
|
+
/**
|
|
56
|
+
* CSRF double-submit 헤더 자동 부착 (core `csrfHeaderFor` — 골든 벡터 XC-01~08). 채팅 스트림은 보통
|
|
57
|
+
* POST 라 쿠키 모드에서 필요하다. `true` = 기본값(쿠키 `XSRF-TOKEN` → 헤더 `X-XSRF-TOKEN`, 같은 출처만),
|
|
58
|
+
* 객체 = `CsrfOptions`(`allowedOrigins`·`readCookie` 등). 미지정/false = 부착 안 함 (현행 동작).
|
|
59
|
+
*
|
|
60
|
+
* 비안전 메서드 + CSRF 쿠키 존재 + 같은 출처(상대 URL 포함) 또는 `allowedOrigins` 일 때만 붙이고,
|
|
61
|
+
* `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않는다. 재연결 시도마다 쿠키를 다시 읽는다.
|
|
62
|
+
*/
|
|
63
|
+
csrf?: boolean | CsrfOptions;
|
|
49
64
|
}
|
|
50
65
|
interface UseSseReturn {
|
|
51
66
|
/**
|
|
@@ -73,6 +88,7 @@ interface UseSseReturn {
|
|
|
73
88
|
* - 구독 전 검증 실패(!res.ok — CommonResponse JSON 에러 경로)는 봉투의 message 를 error 로 노출.
|
|
74
89
|
* - 스트림 시작 후 오류는 in-band error 프레임으로 수신되어 동일하게 error 로 노출.
|
|
75
90
|
* - 언마운트 시 진행 중 스트림을 자동 중단한다.
|
|
91
|
+
* - 쿠키 인증 모드(contracts/session-auth.md)는 `credentials` + `csrf` 옵션으로 — 호출자 `init` 이 우선한다.
|
|
76
92
|
* - 종료 사유는 finishReason 으로 구분한다 — 'done'([DONE] 수신) / 'aborted'(중단) /
|
|
77
93
|
* 'interrupted'([DONE] 없이 끊김). reconnect 옵트인 시 'interrupted' 성 종료 중
|
|
78
94
|
* "스트림 시작 후 끊김"만 지수 백오프로 전체 재요청한다 ({@link SseReconnectOptions}).
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SseSource } from '@rscc/common-core';
|
|
1
|
+
import { CsrfOptions, SseSource } from '@rscc/common-core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* value 가 delayMs(ms) 동안 더 이상 바뀌지 않으면 그 값을 반환한다.
|
|
@@ -46,6 +46,21 @@ interface UseSseOptions {
|
|
|
46
46
|
* 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것).
|
|
47
47
|
*/
|
|
48
48
|
reconnect?: SseReconnectOptions;
|
|
49
|
+
/**
|
|
50
|
+
* fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
|
|
51
|
+
* 같은 출처 `"same-origin"`(fetch 기본값), 교차 출처(서브도메인 API) `"include"`.
|
|
52
|
+
* 미지정 = 속성 자체를 넘기지 않음 (현행 동작). `start(url, init)` 의 `init.credentials` 가 우선한다.
|
|
53
|
+
*/
|
|
54
|
+
credentials?: RequestCredentials;
|
|
55
|
+
/**
|
|
56
|
+
* CSRF double-submit 헤더 자동 부착 (core `csrfHeaderFor` — 골든 벡터 XC-01~08). 채팅 스트림은 보통
|
|
57
|
+
* POST 라 쿠키 모드에서 필요하다. `true` = 기본값(쿠키 `XSRF-TOKEN` → 헤더 `X-XSRF-TOKEN`, 같은 출처만),
|
|
58
|
+
* 객체 = `CsrfOptions`(`allowedOrigins`·`readCookie` 등). 미지정/false = 부착 안 함 (현행 동작).
|
|
59
|
+
*
|
|
60
|
+
* 비안전 메서드 + CSRF 쿠키 존재 + 같은 출처(상대 URL 포함) 또는 `allowedOrigins` 일 때만 붙이고,
|
|
61
|
+
* `init.headers` 에 같은 헤더가 이미 있으면 덮어쓰지 않는다. 재연결 시도마다 쿠키를 다시 읽는다.
|
|
62
|
+
*/
|
|
63
|
+
csrf?: boolean | CsrfOptions;
|
|
49
64
|
}
|
|
50
65
|
interface UseSseReturn {
|
|
51
66
|
/**
|
|
@@ -73,6 +88,7 @@ interface UseSseReturn {
|
|
|
73
88
|
* - 구독 전 검증 실패(!res.ok — CommonResponse JSON 에러 경로)는 봉투의 message 를 error 로 노출.
|
|
74
89
|
* - 스트림 시작 후 오류는 in-band error 프레임으로 수신되어 동일하게 error 로 노출.
|
|
75
90
|
* - 언마운트 시 진행 중 스트림을 자동 중단한다.
|
|
91
|
+
* - 쿠키 인증 모드(contracts/session-auth.md)는 `credentials` + `csrf` 옵션으로 — 호출자 `init` 이 우선한다.
|
|
76
92
|
* - 종료 사유는 finishReason 으로 구분한다 — 'done'([DONE] 수신) / 'aborted'(중단) /
|
|
77
93
|
* 'interrupted'([DONE] 없이 끊김). reconnect 옵트인 시 'interrupted' 성 종료 중
|
|
78
94
|
* "스트림 시작 후 끊김"만 지수 백오프로 전체 재요청한다 ({@link SseReconnectOptions}).
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,14 @@ 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
|
+
readSseStream,
|
|
17
|
+
retry
|
|
18
|
+
} from "@rscc/common-core";
|
|
15
19
|
var RECONNECT_MARKER = new Error("rscc-sse-reconnect");
|
|
16
20
|
function useSse(options = {}) {
|
|
17
|
-
const { fetchImpl, reconnect } = options;
|
|
21
|
+
const { fetchImpl, reconnect, credentials, csrf } = options;
|
|
18
22
|
const [text, setText] = useState2("");
|
|
19
23
|
const [sources, setSources] = useState2(null);
|
|
20
24
|
const [conversationId, setConversationId] = useState2(null);
|
|
@@ -40,6 +44,22 @@ function useSse(options = {}) {
|
|
|
40
44
|
setFinishReason(null);
|
|
41
45
|
setIsStreaming(true);
|
|
42
46
|
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
47
|
+
const requestInit = () => {
|
|
48
|
+
const merged = { ...init, signal: controller.signal };
|
|
49
|
+
const effectiveCredentials = init?.credentials ?? credentials;
|
|
50
|
+
if (effectiveCredentials !== void 0) merged.credentials = effectiveCredentials;
|
|
51
|
+
if (csrf) {
|
|
52
|
+
const csrfHeader = csrfHeaderFor(url, init?.method, csrf);
|
|
53
|
+
if (csrfHeader) {
|
|
54
|
+
const headers = new Headers(init?.headers);
|
|
55
|
+
if (!headers.has(csrfHeader[0])) {
|
|
56
|
+
headers.set(csrfHeader[0], csrfHeader[1]);
|
|
57
|
+
merged.headers = headers;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return merged;
|
|
62
|
+
};
|
|
43
63
|
let reason = "interrupted";
|
|
44
64
|
const attemptStream = async (attempt) => {
|
|
45
65
|
if (attempt > 0) resetStreamState();
|
|
@@ -47,7 +67,7 @@ function useSse(options = {}) {
|
|
|
47
67
|
let sawErrorFrame = false;
|
|
48
68
|
let streamStarted = false;
|
|
49
69
|
try {
|
|
50
|
-
const res = await fetchFn(url,
|
|
70
|
+
const res = await fetchFn(url, requestInit());
|
|
51
71
|
if (!res.ok) {
|
|
52
72
|
const json = await res.json().catch(() => null);
|
|
53
73
|
setError(typeof json?.message === "string" ? json.message : `HTTP ${res.status}`);
|
|
@@ -113,7 +133,7 @@ function useSse(options = {}) {
|
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
135
|
},
|
|
116
|
-
[fetchImpl, reconnect]
|
|
136
|
+
[fetchImpl, reconnect, credentials, csrf]
|
|
117
137
|
);
|
|
118
138
|
useEffect2(() => () => abortRef.current?.abort(), []);
|
|
119
139
|
return { start, cancel, text, sources, conversationId, error, isStreaming, finishReason };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rscc/common-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "RSCC 공통 React 훅 — useDebounce, useSse (@rscc/common-core 의 SSE 파서 래핑).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rscc",
|
|
@@ -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.4.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"react": ">=18"
|