@rscc/common-core 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 CHANGED
@@ -1,6 +1,10 @@
1
1
  # @rscc/common-core
2
2
 
3
- RSCC 공통 코어 — `CommonResponse` 봉투 타입, API 클라이언트 팩토리(traceId 발신/에코), SSE 프레임 파서, JWT 디코더, 시크릿 마스킹 유틸. **프레임워크 무관, 런타임 의존성 0.**
3
+ RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키·쿠키 인증/CSRF), SSE 파서,
4
+ 회복탄력성 유틸(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안 유틸(마스킹·로그 리댁션·
5
+ 웹훅 서명·JWT 디코드), 와이어 계약 헬퍼(날짜·목록 쿼리·벌크·업로드·피처 플래그), 한국 도메인 유틸.
6
+ **프레임워크 무관, 런타임 의존성 0.** 같은 라이브러리의 Java·Python 구현과 동일한 와이어 계약·공유
7
+ 테스트 벡터로 시맨틱을 맞춘다.
4
8
 
5
9
  ## 설치
6
10
 
@@ -8,43 +12,160 @@ RSCC 공통 코어 — `CommonResponse` 봉투 타입, API 클라이언트 팩
8
12
  npm i @rscc/common-core
9
13
  ```
10
14
 
11
- - **Node >= 18** (전역 `fetch` / `Headers` / `ReadableStream` 전제)
15
+ - **Node >= 20** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
12
16
  - ESM + CJS 듀얼 빌드, 타입 선언(`.d.ts` / `.d.cts`) 동봉, `sideEffects: false`
17
+ - 메인 엔트리 `@rscc/common-core` 는 브라우저 안전. Node 전용 기능은 서브패스 `@rscc/common-core/crypto` 로만 제공
13
18
 
14
- ## 공개 API
19
+ ## 모듈 한눈에
15
20
 
16
21
  | 모듈 | 공개 API | 설명 |
17
22
  |---|---|---|
18
- | types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` | 응답 봉투·페이지네이션(page 0 시작)·결과 코드(값은 문자열) |
19
- | apiClient | `createApiClient(config)` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiResult<T>` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 |
20
- | sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크/멀티바이트 경계 안전, 모르는 키는 skip |
23
+ | types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` · `FieldErrorDetail` · `ValidationErrorData` · `isValidationErrorData` | 응답 봉투 · 페이지(page 0 시작) · 결과 코드(값은 문자열) · 검증 오류 봉투 타입 가드 |
24
+ | apiClient | `createApiClient` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiClientRetryOptions` · `ApiResult<T>` · `ApiRequestInfo` / `ApiResponseInfo` / `ApiErrorInfo` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 · opt-in 재시도/멱등성 키/`strictJson`/관측 훅 · 인증 모드(`getToken` bearer / `credentials`+`csrf` 쿠키) |
25
+ | csrf | `csrfHeaderFor` · `readCookie` · `isUnsafeMethod` · `DEFAULT_CSRF_COOKIE_NAME` · `DEFAULT_CSRF_HEADER_NAME` · `CsrfOptions` | CSRF double-submit 헤더 계산 — 비안전 메서드 + 쿠키 존재 + 같은 출처/`allowedOrigins` 일 때만. SSR 안전 |
26
+ | sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크·멀티바이트 경계 안전, 모르는 키는 skip |
21
27
  | jwt | `decodeJwtPayload` · `getTokenExpiry` · `isTokenExpired` | base64url 디코드만 — **서명 미검증**, 만료 판단은 fail-closed |
22
- | masking | `maskSecret` | 앞4+뒤4 노출, 8자 이하 전량 마스킹 (java `MaskingUtils` 와 동일 규칙) |
28
+ | masking | `maskSecret` · `maskName` · `maskPhone` · `maskEmail` · `maskCardNumber` | 시크릿(앞4+뒤4)·이름·휴대폰·이메일·카드번호 마스킹 |
29
+ | datetime | `toWireDateTime` · `toWireDate` · `parseWireDateTime` · `stripZone` | 오프셋 없는 LocalDateTime 와이어 형식 — `toISOString()`(Z) 의 9시간 스큐 차단 |
30
+ | chosung | `toChosung` · `isChosungQuery` | 한글 초성 변환·초성 질의 판별 (자동완성) |
31
+ | retry | `retry` · `isRetryableStatus` · `parseRetryAfterMs` · `RetryOptions` | 지수 백오프 + full jitter, 시간 예산, `Retry-After` 파싱 |
32
+ | cache | `createTtlCache` · `TtlCache<K,V>` · `TtlCacheOptions` | TTL 캐시 + single-flight(동일 키 로더 1회 공유), lazy expiry |
33
+ | circuitBreaker | `createCircuitBreaker` · `CircuitOpenError` · `CircuitBreaker` · `CircuitBreakerOptions` · `CircuitState` | 연속 실패 서킷 브레이커 — `execute(fn)` 권장, 취소는 무집계(`onIgnore`) |
34
+ | tokenBucket | `createTokenBucket` · `TokenBucket` · `TokenBucketOptions` | 토큰버킷 레이트리미터 (연속 리필, 대기 없이 즉시 판정) |
35
+ | bulkhead | `createBulkhead` · `BulkheadFullError` · `Bulkhead` · `BulkheadOptions` | 동시 실행 + 대기 슬롯 격벽, 만석 즉시 거부 |
36
+ | logSanitize | `sanitizeLogValue` | 로그 인젝션 방지 — 제어 문자 공백 치환 + 길이 절단 |
37
+ | bizno | `normalizeBusinessNumber` · `isValidBusinessNumber` · `isValidCorporateNumber` | 사업자(10)·법인(13)번호 체크섬 검증 |
38
+ | idempotency | `generateIdempotencyKey` | 멱등성 키 생성 (apiClient `idempotency` 옵션으로 자동 부착 가능) |
39
+ | webhook | `signWebhook` · `verifyWebhook` · `WEBHOOK_SIGNATURE_HEADER` | HMAC-SHA256 웹훅 서명/검증 (WebCrypto — async), 시크릿 로테이션 |
40
+ | bulk | `isBulkResult` · `bulkFailures` · `createBulkResultBuilder` · `BulkResult` · `BulkResultItem` · `BulkResultBuilder` | 벌크/부분 실패 봉투 판별·생성 |
41
+ | upload | `sniffFile` · `kindsForExtension` · `validateUpload` · `FileKind` · `UploadValidationResult` | 업로드 프리검증 — 크기·확장자·매직바이트 대조 |
42
+ | query | `buildListQuery` · `ListQueryOptions` · `SortParam` | 목록 조회 쿼리(page/size/sort/필터) 빌더 |
43
+ | featureFlags | `parseFlag` · `createFeatureFlags` · `FeatureFlagReader` | 피처 플래그 파싱 (참 집합 `true`/`1`/`on`/`yes`) |
44
+ | rrn | `normalizeRrn` · `isValidRrn` · `isForeignerRrn` · `rrnChecksumOkLegacy` · `rrnBirthDate` | 주민등록번호 형식·생년월일 검증 (기본 검증은 **체크섬 미포함**) |
45
+ | josa | `pickJosa` · `attachJosa` · `JosaPair` | 은/는·이/가 등 조사 자동 선택 |
46
+ | age | `ageMan` · `ageByYear` · `ageInsurance` | 만 나이·연 나이·보험 나이 (입력 `"YYYY-MM-DD"` 문자열) |
47
+ | phone | `normalizePhoneNumber` · `classifyPhoneNumber` · `formatPhoneNumber` · `toE164` · `PhoneType` | 전화번호 정규화·분류·포맷·E.164 변환 |
48
+ | money | `toKoreanWords` · `toFormalNotation` · `abbreviateAmount` | 금액 한글 수사 · 공문서 표기(`금…원整`) · UI 약식(`1.2억`) |
49
+ | businessDays | `createBusinessDays` · `BusinessDays` · `BusinessDaysOptions` | 영업일 계산 (공휴일 주입형 — 내장 공휴일 없음) |
50
+ | jamo | `decomposeHangul` · `composeHangul` · `matchesHangul` | 두벌식 자모 분해·조합·혼합 질의 매칭 |
51
+ | **crypto (서브패스)** | `encryptAesGcm` · `decryptAesGcm` · `CryptoError` — `@rscc/common-core/crypto` | AES-256/GCM (Java/Python 와이어 호환). **Node 전용** (`node:crypto`) |
23
52
 
24
53
  ## 사용 예시
25
54
 
26
55
  ### createApiClient — CommonResponse 언랩 + traceId
27
56
 
28
57
  ```ts
29
- import { createApiClient, ApiError } from "@rscc/common-core";
58
+ import { createApiClient, ApiError, isValidationErrorData } from "@rscc/common-core";
30
59
 
31
60
  const api = createApiClient({
32
- baseUrl: "https://api.example.com", // 끝 슬래시 없이
33
- getToken: () => localStorage.getItem("token"), // 선택 — Authorization: Bearer 부착
61
+ baseUrl: "https://api.example.com", // 끝 슬래시 유무 무관 — 경계 슬래시 자동 단일화
62
+ getToken: () => sessionStorage.getItem("token"), // 선택 — Authorization: Bearer 부착
34
63
  onUnauthorized: () => { /* 로그아웃/리다이렉트 정책은 소비자가 결정 */ },
64
+ // 선택: retry: { retries: 3 }, idempotency: {}, strictJson: true, onRequest/onResponse/onError
35
65
  });
36
66
 
37
- // 성공 봉투 → data 언랩
67
+ // 성공 봉투 → data 언랩 (비봉투 JSON 은 그대로)
38
68
  const user = await api.request<UserInfo>("/api/v1/users/me");
39
69
 
40
- // 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId }
70
+ // 문자열 body → Content-Type: application/json 자동 부착
71
+ await api.request("/api/v1/items", { method: "POST", body: JSON.stringify(item) });
72
+
73
+ // 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId, data, retryAfterMs }
41
74
  try {
42
75
  await api.request("/api/v1/things/999");
43
76
  } catch (e) {
44
- if (e instanceof ApiError) console.error(`[${e.traceId}] ${e.code}: ${e.message}`);
77
+ if (e instanceof ApiError) {
78
+ console.error(`[${e.traceId}] ${e.code}: ${e.message}`);
79
+ if (isValidationErrorData(e.data)) { /* e.data.errors — 필드별 검증 오류 */ }
80
+ }
45
81
  }
82
+
83
+ // traceId·status·원본 Response 가 필요하면
84
+ const { data, traceId, response } = await api.requestWithMeta<UserInfo>("/api/v1/users/me");
85
+ ```
86
+
87
+ - **Content-Type**: 호출자가 지정하지 않았고 body 가 **문자열**일 때만 `application/json` 을 붙인다.
88
+ body 없는 요청(GET 등)엔 붙이지 않으므로 교차 출처 GET 이 CORS preflight 를 유발하지 않는다.
89
+ `URLSearchParams`/`Blob`/`FormData`/`ArrayBuffer`/`ReadableStream` 은 fetch 기본값을 따른다.
90
+ - **traceId**: 논리 호출당 1회 생성해 `X-Trace-Id` 로 부착(재시도 전 시도 동일), 응답 에코를 우선 노출.
91
+ - **retry** (opt-in): 멱등 메서드(GET/HEAD/OPTIONS/PUT/DELETE)만 기본 재시도, 408/429/5xx 일부·네트워크
92
+ 오류 대상, `Retry-After` 를 대기 하한으로 존중. POST/PATCH 는 `methods` 로 명시 옵트인.
93
+ - **idempotency** (opt-in): POST/PATCH 에 `Idempotency-Key` 를 논리 호출당 1회 생성해 부착(호출자 헤더 우선).
94
+ - **strictJson** (opt-in): 2xx 의 비-JSON 본문을 null 대신 `INVALID_JSON` ApiError 로 실패(`rawText` 앞 2048자
95
+ 보존). 빈 본문(204 등)은 계속 null.
96
+ - `requestWithMeta` 의 `response` 는 **본문 미소비 원본** — 파싱은 `clone()` 사본으로 하므로 `text()`/`json()`
97
+ 재호출이 가능하다.
98
+
99
+ ### 인증 모드 — bearer vs 쿠키(jwt-cookie / session) + CSRF
100
+
101
+ 서버가 인증 자격을 어디로 받는지(서버 설정의 인증 모드)에 맞춰 클라이언트 설정을 고른다.
102
+
103
+ | 서버 모드 | 자격 운반 | 클라이언트 설정 |
104
+ |---|---|---|
105
+ | `bearer` (기본) | `Authorization: Bearer <JWT>` | `getToken` |
106
+ | `jwt-cookie` | HttpOnly 쿠키 (JS 가 토큰을 못 봄) | `credentials` + `csrf` |
107
+ | `session` | 서버 세션 쿠키 | `credentials` + `csrf` |
108
+
109
+ ```ts
110
+ // bearer — 토큰을 앱이 보관하고 헤더로 싣는다 (앱·서버 간 호출, 모바일 등)
111
+ const bearerApi = createApiClient({ baseUrl: "https://api.example.com", getToken: () => tokenStore.get() });
112
+
113
+ // jwt-cookie / session — 같은 출처(리버스 프록시로 프론트와 API 를 한 오리진에, 권장)
114
+ const api = createApiClient({ baseUrl: "/api", credentials: "same-origin", csrf: true });
115
+
116
+ // jwt-cookie / session — 같은 사이트 서브도메인 (app.example.com → api.example.com)
117
+ const subdomainApi = createApiClient({
118
+ baseUrl: "https://api.example.com",
119
+ credentials: "include", // 교차 출처로 쿠키 전송
120
+ csrf: { allowedOrigins: ["https://api.example.com"] }, // 이 오리진에만 CSRF 헤더 허용
121
+ });
122
+
123
+ // 로그인 상태 확인은 토큰 만료 계산(isTokenExpired) 대신 서버 엔드포인트로
124
+ const me = await api.request<UserInfo>("/api/v1/users/me").catch(() => null);
125
+ ```
126
+
127
+ - **`credentials`**: fetch `credentials` 기본값. 미지정이면 속성 자체를 넘기지 않는다(fetch 기본 `"same-origin"`).
128
+ 호출별 `init.credentials` 가 우선한다.
129
+ - **`csrf`** (double-submit): 서버가 내려준 HttpOnly 아닌 쿠키 `XSRF-TOKEN` 값을 `X-XSRF-TOKEN` 헤더로 되돌려 보낸다.
130
+ **비안전 메서드**(GET·HEAD·OPTIONS·TRACE 외)이고 쿠키가 있으며 요청 URL 이 **같은 출처**(상대 URL 포함)이거나
131
+ `allowedOrigins` 에 속할 때만 붙인다 — 교차 출처로는 보내지 않는다(토큰 유출 방지). 호출자가 이미 실은 헤더는
132
+ 덮어쓰지 않고, 재시도마다 쿠키를 다시 읽는다(토큰 회전 대응).
133
+ - `CsrfOptions`: `cookieName`(기본 `XSRF-TOKEN`) · `headerName`(기본 `X-XSRF-TOKEN`) · `allowedOrigins`(오리진 정규화 비교,
134
+ 와일드카드 없음) · `readCookie`(원시 쿠키 문자열 공급자 — `document` 가 없는 React Native·테스트용).
135
+ - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않고 오류도 내지 않는다.
136
+ 모듈 로드 시점에 `document`/`location` 에 접근하지 않는다.
137
+ - 쿠키 모드의 웹 클라이언트는 **토큰을 localStorage/sessionStorage 에 저장하지 않는다** — 웹이 다시 헤더 토큰을 쓰면
138
+ HttpOnly 쿠키로 막은 XSS 토큰 탈취 경로가 되살아난다(`getToken` 은 앱·서버 간 호출용).
139
+ - 서브도메인 토폴로지는 서버 쪽도 맞춰야 한다 — CSRF 쿠키 `Domain` 을 상위 도메인으로(프론트 JS 가 읽도록), CORS 가
140
+ 자격 허용(`Access-Control-Allow-Credentials: true` + 명시 오리진)과 `X-XSRF-TOKEN` 요청 헤더를 허용.
141
+
142
+ 헬퍼를 직접 쓸 수도 있다 (axios 인터셉터 등 다른 HTTP 클라이언트 — 서버는 CSRF 토큰을 헤더로만 받는다):
143
+
144
+ ```ts
145
+ import { csrfHeaderFor, readCookie } from "@rscc/common-core";
146
+
147
+ csrfHeaderFor("/api/v1/items", "POST", true); // ["X-XSRF-TOKEN", "<쿠키 값>"] | null
148
+ readCookie("XSRF-TOKEN"); // document.cookie 에서 첫 값 (디코딩 안 함) | null
46
149
  ```
47
150
 
151
+ ### 서킷 브레이커 — execute 권장
152
+
153
+ ```ts
154
+ import { createCircuitBreaker, CircuitOpenError, retry } from "@rscc/common-core";
155
+
156
+ const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
157
+
158
+ // 게이트 → 실행 → 결과 보고를 정확히 1회 보장. OPEN 이면 fn 미호출 + CircuitOpenError
159
+ await retry(() => cb.execute(() => api.request("/api/v1/downstream")), {
160
+ shouldRetry: (e) => !(e instanceof CircuitOpenError), // OPEN 차단은 즉시 실패
161
+ });
162
+ ```
163
+
164
+ - resolve → 성공, `name === "AbortError"` → 무집계(`onIgnore`), 그 외 throw → 실패로 집계 후 **같은 오류를 그대로** rethrow.
165
+ - 수동 게이트(`allowRequest()` + `onSuccess`/`onFailure`/`onIgnore` 중 정확히 1회)도 지원한다.
166
+ - 보고가 끝내 오지 않은 그랜트는 OPEN 진입 후 `2 × openDurationMs` 에 유실로 용서되어 OPEN 영구 고착을 막는다.
167
+ HALF_OPEN 프로브도 `openDurationMs` 동안 미보고면 유실로 간주해 새 프로브를 넘긴다(HALF_OPEN 고착 방지).
168
+
48
169
  ### SSE 스트림 소비
49
170
 
50
171
  ```ts
@@ -52,24 +173,44 @@ import { readSseStream } from "@rscc/common-core";
52
173
 
53
174
  const res = await fetch(streamUrl, { method: "POST", body, signal });
54
175
  await readSseStream(res, {
55
- onDelta: (chunk) => { /* 텍스트 증분 */ },
176
+ onConversationId: (id) => { /* 첫 프레임 */ },
56
177
  onSources: (sources) => { /* RAG 근거 */ },
178
+ onDelta: (chunk) => { /* 텍스트 증분 */ },
57
179
  onError: (message) => { /* in-band 오류 */ },
58
180
  onDone: () => { /* data: [DONE] */ },
59
181
  });
60
182
  ```
61
183
 
62
- ## 알려진 제약
184
+ ### AES-256/GCM — Node 전용 서브패스
63
185
 
64
- - SSE 파서는 프레임 구분자 **LF(`\n\n`) 고정** — CRLF 로 정규화하는 프록시 뒤에서는 프레임이 분리되지 않는다.
65
- - `baseUrl` 은 **끝 슬래시 없이** 지정할 것 — path 와 단순 연결하며 정규화하지 않는다.
66
- - `requestWithMeta` 의 `ApiResult.response` 는 body 가 이미 소비된 상태 — headers/status 조회용.
67
- - 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 조용히 null 이 된다.
68
- - JWT 디코더는 **서명을 검증하지 않는다** — 표시·만료 판단 전용. 인가 판단은 반드시 서버에서.
186
+ ```ts
187
+ import { encryptAesGcm, decryptAesGcm, CryptoError } from "@rscc/common-core/crypto";
69
188
 
70
- ## 문서 / 저장소
189
+ const enc = encryptAesGcm("민감값", key); // Base64(IV(12B)∥암호문∥태그(16B)) — 매번 다름
190
+ const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
191
+ ```
192
+
193
+ ## 알려진 제약
71
194
 
72
- - 상세 문서: [js/README.md](https://github.com/Jeonghyeon-Ryu/r-common/blob/master/js/README.md)
73
- - 와이어 계약(단일 소스): [contracts/](https://github.com/Jeonghyeon-Ryu/r-common/tree/master/contracts)
74
- - React 훅: [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
75
- - 저장소: [Jeonghyeon-Ryu/r-common](https://github.com/Jeonghyeon-Ryu/r-common) · MIT
195
+ - **SSE 파서는 프레임 구분자 LF(`\n\n`) 고정** — 개행을 CRLF 로 정규화하는 프록시 뒤에서는 프레임이 분리되지 않는다.
196
+ - 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 기본적으로 조용히 null 이 된다 — 명시 실패가 필요하면 `strictJson: true`.
197
+ - 문자열 body 는 `application/json` 으로 간주된다 — JSON 이 아닌 문자열 본문은 Content-Type 을 직접 지정할 것.
198
+ - 교차 출처에서 traceId 에코를 읽으려면 서버가 `Access-Control-Expose-Headers: X-Trace-Id` 를 내려야 한다
199
+ (못 읽으면 발신 값을 노출 — 서버가 수신 traceId 를 재사용하므로 동일 값).
200
+ - 1회성 body(`ReadableStream`/`FormData`)는 재전송이 불가해 `retry` 를 지정해도 1회만 실행된다.
201
+ - JWT 디코더는 **서명을 검증하지 않는다** — 표시·만료 판단 전용. 인가 판단은 반드시 서버에서.
202
+ 쿠키 인증 모드(HttpOnly 쿠키)에서는 JS 가 토큰을 볼 수 없으므로 `/me` 같은 서버 엔드포인트로 상태를 확인할 것.
203
+ - `csrf` 는 교차 출처 요청에 헤더를 붙이지 않는다 — 서브도메인 API 는 `allowedOrigins` 에 명시해야 한다.
204
+ CSRF 쿠키를 읽으려면 그 쿠키가 프론트 페이지 도메인에서 보여야 한다(서버 `Domain` 설정).
205
+ - `isValidRrn` 은 체크섬을 포함하지 않는다(2020-10 이후 발급분은 체크섬 불성립) — `rrnChecksumOkLegacy` 는 레거시 정합 검사 전용.
206
+ - `signWebhook`/`verifyWebhook` 은 WebCrypto 기반이라 **async** 다 (Java/Python 은 동기).
207
+ - 서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시는 **인스턴스(프로세스) 로컬** 상태다 — 분산 공유되지 않는다.
208
+ - `@rscc/common-core/crypto` 는 `node:crypto` 를 쓰는 **Node 전용** 서브패스 — 브라우저 번들에 포함하지 말 것.
209
+ - 서브패스는 `exports` 맵으로만 노출된다 — TypeScript `moduleResolution` 이 레거시 `node`(`node10`)면
210
+ `@rscc/common-core/crypto` 의 타입을 찾지 못한다. `node16` / `nodenext` / `bundler` 를 사용할 것
211
+ (메인 엔트리 `@rscc/common-core` 는 모든 모드에서 해석된다).
212
+
213
+ ## 관련 패키지
214
+
215
+ - React 훅(`useDebounce`, `useSse`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
216
+ - 라이선스: MIT
package/dist/index.cjs CHANGED
@@ -22,6 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ApiError: () => ApiError,
24
24
  BulkheadFullError: () => BulkheadFullError,
25
+ CircuitOpenError: () => CircuitOpenError,
26
+ DEFAULT_CSRF_COOKIE_NAME: () => DEFAULT_CSRF_COOKIE_NAME,
27
+ DEFAULT_CSRF_HEADER_NAME: () => DEFAULT_CSRF_HEADER_NAME,
25
28
  ResultCode: () => ResultCode,
26
29
  WEBHOOK_SIGNATURE_HEADER: () => WEBHOOK_SIGNATURE_HEADER,
27
30
  abbreviateAmount: () => abbreviateAmount,
@@ -41,6 +44,7 @@ __export(index_exports, {
41
44
  createFeatureFlags: () => createFeatureFlags,
42
45
  createTokenBucket: () => createTokenBucket,
43
46
  createTtlCache: () => createTtlCache,
47
+ csrfHeaderFor: () => csrfHeaderFor,
44
48
  decodeJwtPayload: () => decodeJwtPayload,
45
49
  decomposeHangul: () => decomposeHangul,
46
50
  formatPhoneNumber: () => formatPhoneNumber,
@@ -51,6 +55,7 @@ __export(index_exports, {
51
55
  isForeignerRrn: () => isForeignerRrn,
52
56
  isRetryableStatus: () => isRetryableStatus,
53
57
  isTokenExpired: () => isTokenExpired,
58
+ isUnsafeMethod: () => isUnsafeMethod,
54
59
  isValidBusinessNumber: () => isValidBusinessNumber,
55
60
  isValidCorporateNumber: () => isValidCorporateNumber,
56
61
  isValidRrn: () => isValidRrn,
@@ -70,6 +75,7 @@ __export(index_exports, {
70
75
  parseSseFrame: () => parseSseFrame,
71
76
  parseWireDateTime: () => parseWireDateTime,
72
77
  pickJosa: () => pickJosa,
78
+ readCookie: () => readCookie,
73
79
  readSseStream: () => readSseStream,
74
80
  retry: () => retry,
75
81
  rrnBirthDate: () => rrnBirthDate,
@@ -111,6 +117,93 @@ var ResultCode = {
111
117
  INTERNAL_SERVER_ERROR: "500"
112
118
  };
113
119
 
120
+ // src/csrf.ts
121
+ var DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
122
+ var DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
123
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
124
+ var SENTINEL_ORIGIN = "http://rscc-csrf.invalid";
125
+ var ABSOLUTE_URL = /^([a-zA-Z][a-zA-Z\d+.-]*):\/\/([^/?#\\]*)/;
126
+ var HAS_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
127
+ var PROTOCOL_RELATIVE = /^[\\/]{2}/;
128
+ var DEFAULT_PORTS = { http: "80", https: "443", ws: "80", wss: "443" };
129
+ function readCookie(name, cookieString) {
130
+ const source = cookieString ?? documentCookie();
131
+ if (!source) return null;
132
+ for (const part of source.split(";")) {
133
+ const pair = part.trim();
134
+ const eq = pair.indexOf("=");
135
+ if (eq < 0) continue;
136
+ if (pair.slice(0, eq) === name) return pair.slice(eq + 1);
137
+ }
138
+ return null;
139
+ }
140
+ function isUnsafeMethod(method) {
141
+ return !SAFE_METHODS.has((method || "GET").toUpperCase());
142
+ }
143
+ function csrfHeaderFor(url, method, options) {
144
+ if (!isUnsafeMethod(method)) return null;
145
+ const opts = options === true || options === void 0 ? {} : options;
146
+ const cookieString = opts.readCookie ? opts.readCookie() : documentCookie();
147
+ if (!cookieString) return null;
148
+ const token = readCookie(opts.cookieName ?? DEFAULT_CSRF_COOKIE_NAME, cookieString);
149
+ if (!token) return null;
150
+ if (!isAllowedTarget(url, opts.allowedOrigins)) return null;
151
+ return [opts.headerName ?? DEFAULT_CSRF_HEADER_NAME, token];
152
+ }
153
+ function documentCookie() {
154
+ if (typeof document === "undefined") return null;
155
+ try {
156
+ return typeof document.cookie === "string" ? document.cookie : null;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ function currentLocation() {
162
+ if (typeof location === "undefined") return null;
163
+ try {
164
+ const { href, origin } = location;
165
+ return typeof href === "string" && typeof origin === "string" ? { href, origin } : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ function isAllowedTarget(url, allowedOrigins) {
171
+ const loc = currentLocation();
172
+ const base = loc ? loc.href : SENTINEL_ORIGIN;
173
+ const selfOrigin = loc ? loc.origin : SENTINEL_ORIGIN;
174
+ const target = originOf(url, base, selfOrigin);
175
+ if (target === null || target === "null") return false;
176
+ if (selfOrigin !== "null" && target === selfOrigin) return true;
177
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
178
+ return allowedOrigins.some((entry) => originOf(entry, base, selfOrigin) === target);
179
+ }
180
+ function originOf(url, base, baseOrigin) {
181
+ try {
182
+ if (typeof URL === "function") {
183
+ const origin = new URL(url, base).origin;
184
+ if (typeof origin === "string") return origin;
185
+ }
186
+ } catch {
187
+ }
188
+ return looseOriginOf(url, baseOrigin);
189
+ }
190
+ function looseOriginOf(url, baseOrigin) {
191
+ const trimmed = url.trim();
192
+ const m = ABSOLUTE_URL.exec(trimmed);
193
+ if (m) {
194
+ const scheme = m[1].toLowerCase();
195
+ const authority = m[2];
196
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1).toLowerCase();
197
+ const hp = /^(.*?)(?::(\d*))?$/.exec(hostPort);
198
+ const host = hp?.[1] ?? "";
199
+ const port = hp?.[2] ?? "";
200
+ if (!host) return null;
201
+ return port === "" || port === DEFAULT_PORTS[scheme] ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
202
+ }
203
+ if (PROTOCOL_RELATIVE.test(trimmed) || HAS_SCHEME.test(trimmed)) return null;
204
+ return baseOrigin;
205
+ }
206
+
114
207
  // src/idempotency.ts
115
208
  var FALLBACK_GROUPS = [8, 4, 4, 4, 12];
116
209
  function generateIdempotencyKey() {
@@ -254,6 +347,8 @@ function createApiClient(config) {
254
347
  async function requestWithMeta(path, init = {}) {
255
348
  const sentTraceId = generateTraceId();
256
349
  const method = (init.method ?? "GET").toUpperCase();
350
+ const url = joinUrl(config.baseUrl, path);
351
+ const credentials = init.credentials ?? config.credentials;
257
352
  const idempotencyHeader = config.idempotency?.header ?? "Idempotency-Key";
258
353
  const idempotencyKey = config.idempotency && (config.idempotency.methods ?? DEFAULT_IDEMPOTENCY_METHODS).includes(method) ? generateIdempotencyKey() : null;
259
354
  const attemptOnce = async () => {
@@ -262,16 +357,23 @@ function createApiClient(config) {
262
357
  if (idempotencyKey !== null && !headers.has(idempotencyHeader)) {
263
358
  headers.set(idempotencyHeader, idempotencyKey);
264
359
  }
265
- const isFormData = typeof FormData !== "undefined" && init.body instanceof FormData;
266
- if (!headers.has("Content-Type") && !isFormData) {
360
+ if (!headers.has("Content-Type") && typeof init.body === "string") {
267
361
  headers.set("Content-Type", "application/json");
268
362
  }
269
363
  if (config.getToken && !headers.has("Authorization")) {
270
364
  const token = config.getToken();
271
365
  if (token) headers.set("Authorization", `Bearer ${token}`);
272
366
  }
367
+ if (config.csrf) {
368
+ const csrfHeader = csrfHeaderFor(url, method, config.csrf);
369
+ if (csrfHeader && !headers.has(csrfHeader[0])) headers.set(csrfHeader[0], csrfHeader[1]);
370
+ }
273
371
  const fetchFn = config.fetchImpl ?? globalThis.fetch;
274
- const response = await fetchFn(joinUrl(config.baseUrl, path), { ...init, headers });
372
+ const response = await fetchFn(url, {
373
+ ...init,
374
+ ...credentials !== void 0 ? { credentials } : {},
375
+ headers
376
+ });
275
377
  const traceId = response.headers.get(traceIdHeader) ?? sentTraceId;
276
378
  let raw = null;
277
379
  try {
@@ -685,6 +787,12 @@ function createTtlCache(options) {
685
787
  }
686
788
 
687
789
  // src/circuitBreaker.ts
790
+ var CircuitOpenError = class extends Error {
791
+ constructor(message = "\uC11C\uD0B7 OPEN \u2014 \uC694\uCCAD \uCC28\uB2E8") {
792
+ super(message);
793
+ this.name = "CircuitOpenError";
794
+ }
795
+ };
688
796
  function createCircuitBreaker(options) {
689
797
  const { failureThreshold, openDurationMs, now = Date.now } = options;
690
798
  if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) {
@@ -697,6 +805,7 @@ function createCircuitBreaker(options) {
697
805
  let consecutiveFailures = 0;
698
806
  let openedAtMs = 0;
699
807
  let probeInFlight = false;
808
+ let probeStartedAtMs = 0;
700
809
  let inFlightGrants = 0;
701
810
  function allowRequest() {
702
811
  if (state === "CLOSED") {
@@ -704,18 +813,24 @@ function createCircuitBreaker(options) {
704
813
  return true;
705
814
  }
706
815
  if (state === "OPEN") {
707
- if (now() - openedAtMs >= openDurationMs) {
708
- if (inFlightGrants > 0) {
816
+ const nowMs2 = now();
817
+ const elapsedMs = nowMs2 - openedAtMs;
818
+ if (elapsedMs < openDurationMs) return false;
819
+ if (inFlightGrants > 0) {
820
+ if (elapsedMs < 2 * openDurationMs) {
709
821
  return false;
710
822
  }
711
- state = "HALF_OPEN";
712
- probeInFlight = true;
713
- return true;
823
+ inFlightGrants = 0;
714
824
  }
715
- return false;
825
+ state = "HALF_OPEN";
826
+ probeInFlight = true;
827
+ probeStartedAtMs = nowMs2;
828
+ return true;
716
829
  }
717
- if (probeInFlight) return false;
830
+ const nowMs = now();
831
+ if (probeInFlight && nowMs - probeStartedAtMs < openDurationMs) return false;
718
832
  probeInFlight = true;
833
+ probeStartedAtMs = nowMs;
719
834
  return true;
720
835
  }
721
836
  function onSuccess() {
@@ -750,13 +865,40 @@ function createCircuitBreaker(options) {
750
865
  }
751
866
  inFlightGrants = Math.max(0, inFlightGrants - 1);
752
867
  }
868
+ function onIgnore() {
869
+ if (state === "HALF_OPEN") {
870
+ probeInFlight = false;
871
+ return;
872
+ }
873
+ inFlightGrants = Math.max(0, inFlightGrants - 1);
874
+ }
875
+ async function execute(fn) {
876
+ if (!allowRequest()) {
877
+ throw new CircuitOpenError();
878
+ }
879
+ let result;
880
+ try {
881
+ result = await fn();
882
+ } catch (error) {
883
+ if (isAbortError(error)) onIgnore();
884
+ else onFailure();
885
+ throw error;
886
+ }
887
+ onSuccess();
888
+ return result;
889
+ }
753
890
  return {
754
891
  allowRequest,
755
892
  onSuccess,
756
893
  onFailure,
894
+ onIgnore,
895
+ execute,
757
896
  state: () => state
758
897
  };
759
898
  }
899
+ function isAbortError(error) {
900
+ return typeof error === "object" && error !== null && error.name === "AbortError";
901
+ }
760
902
 
761
903
  // src/tokenBucket.ts
762
904
  function createTokenBucket(options) {
@@ -1738,6 +1880,9 @@ function matchesHangul(query, target) {
1738
1880
  0 && (module.exports = {
1739
1881
  ApiError,
1740
1882
  BulkheadFullError,
1883
+ CircuitOpenError,
1884
+ DEFAULT_CSRF_COOKIE_NAME,
1885
+ DEFAULT_CSRF_HEADER_NAME,
1741
1886
  ResultCode,
1742
1887
  WEBHOOK_SIGNATURE_HEADER,
1743
1888
  abbreviateAmount,
@@ -1757,6 +1902,7 @@ function matchesHangul(query, target) {
1757
1902
  createFeatureFlags,
1758
1903
  createTokenBucket,
1759
1904
  createTtlCache,
1905
+ csrfHeaderFor,
1760
1906
  decodeJwtPayload,
1761
1907
  decomposeHangul,
1762
1908
  formatPhoneNumber,
@@ -1767,6 +1913,7 @@ function matchesHangul(query, target) {
1767
1913
  isForeignerRrn,
1768
1914
  isRetryableStatus,
1769
1915
  isTokenExpired,
1916
+ isUnsafeMethod,
1770
1917
  isValidBusinessNumber,
1771
1918
  isValidCorporateNumber,
1772
1919
  isValidRrn,
@@ -1786,6 +1933,7 @@ function matchesHangul(query, target) {
1786
1933
  parseSseFrame,
1787
1934
  parseWireDateTime,
1788
1935
  pickJosa,
1936
+ readCookie,
1789
1937
  readSseStream,
1790
1938
  retry,
1791
1939
  rrnBirthDate,