@rscc/common-react 0.3.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 +33 -2
- 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, finishReason }` | SSE 구독 상태 훅 — 언마운트 시 자동 중단, `finishReason` 으로 종료 사유 구분, opt-in `reconnect` |
|
|
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
|
|
|
@@ -68,6 +71,34 @@ const sse = useSse({ reconnect: { retries: 3, minDelayMs: 1000, maxDelayMs: 1500
|
|
|
68
71
|
(중복 누적 방지). 이어받기(Last-Event-ID)는 지원하지 않는다.
|
|
69
72
|
- [주의] 비멱등 POST 스트림은 전체 재요청이 서버 측 중복 생성으로 이어질 수 있으므로 멱등성이 보장될 때만 켤 것.
|
|
70
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 })` 로 같은 규칙을 쓴다.
|
|
101
|
+
|
|
71
102
|
## 관련 패키지
|
|
72
103
|
|
|
73
104
|
- 코어(SSE 파서 `readSseStream`·apiClient 등): [@rscc/common-core](https://www.npmjs.com/package/@rscc/common-core)
|
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"
|