@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 +166 -25
- package/dist/index.cjs +158 -10
- package/dist/index.d.cts +174 -32
- package/dist/index.d.ts +174 -32
- package/dist/index.js +152 -10
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @rscc/common-core
|
|
2
2
|
|
|
3
|
-
RSCC 공통 코어 — `CommonResponse` 봉투
|
|
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 >=
|
|
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
|
-
##
|
|
19
|
+
## 모듈 한눈에
|
|
15
20
|
|
|
16
21
|
| 모듈 | 공개 API | 설명 |
|
|
17
22
|
|---|---|---|
|
|
18
|
-
| types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` | 응답
|
|
19
|
-
| apiClient | `createApiClient
|
|
20
|
-
|
|
|
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`
|
|
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: () =>
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
probeInFlight = true;
|
|
713
|
-
return true;
|
|
823
|
+
inFlightGrants = 0;
|
|
714
824
|
}
|
|
715
|
-
|
|
825
|
+
state = "HALF_OPEN";
|
|
826
|
+
probeInFlight = true;
|
|
827
|
+
probeStartedAtMs = nowMs2;
|
|
828
|
+
return true;
|
|
716
829
|
}
|
|
717
|
-
|
|
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,
|