@rscc/common-core 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 CHANGED
@@ -1,8 +1,10 @@
1
1
  # @rscc/common-core
2
2
 
3
- RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키), SSE 파서,
3
+ RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키·쿠키 인증/CSRF·도메인 에러 코드 폴백),
4
+ SSE(WHATWG 표준 파서·리더·빌더 + 채팅 프로필),
4
5
  회복탄력성 유틸(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안 유틸(마스킹·로그 리댁션·
5
- 웹훅 서명·JWT 디코드), 와이어 계약 헬퍼(날짜·목록 쿼리·벌크·업로드·피처 플래그), 한국 도메인 유틸.
6
+ 웹훅 서명·JWT 디코드), 와이어 계약 헬퍼(날짜·목록 쿼리·벌크·업로드·피처 플래그), 메시지 다국어(내장 ko·en),
7
+ 한국 도메인 유틸.
6
8
  **프레임워크 무관, 런타임 의존성 0.** 같은 라이브러리의 Java·Python 구현과 동일한 와이어 계약·공유
7
9
  테스트 벡터로 시맨틱을 맞춘다.
8
10
 
@@ -12,7 +14,7 @@ RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(tra
12
14
  npm i @rscc/common-core
13
15
  ```
14
16
 
15
- - **Node >= 18** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
17
+ - **Node >= 20** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
16
18
  - ESM + CJS 듀얼 빌드, 타입 선언(`.d.ts` / `.d.cts`) 동봉, `sideEffects: false`
17
19
  - 메인 엔트리 `@rscc/common-core` 는 브라우저 안전. Node 전용 기능은 서브패스 `@rscc/common-core/crypto` 로만 제공
18
20
 
@@ -20,9 +22,12 @@ npm i @rscc/common-core
20
22
 
21
23
  | 모듈 | 공개 API | 설명 |
22
24
  |---|---|---|
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`/관측 훅 |
25
- | sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크·멀티바이트 경계 안전, 모르는 키는 skip |
25
+ | types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` · `ErrorCodeValue` · `FieldErrorDetail` · `ValidationErrorData` · `isValidationErrorData` | 응답 봉투 · 페이지(page 0 시작) · 결과 코드(값은 문자열) · 봉투 code 타입(공통 코드 자동완성 + 도메인 코드 string) · 검증 오류 봉투 타입 가드 |
26
+ | errorCodes | `isCommonResultCode` · `resultCodeForStatus` · `toCommonResultCode` | 도메인 에러 코드 폴백 — 모르는 code 는 HTTP 상태로 공통 코드 유도 (정확 일치 → 4xx `"400"` → `"500"`) |
27
+ | apiClient | `createApiClient` · `ApiError`(`code` 원문 + `commonCode` 정규화) · `ApiClient` · `ApiClientConfig` · `ApiClientRetryOptions` · `ApiResult<T>` · `ApiRequestInfo` / `ApiResponseInfo` / `ApiErrorInfo` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 · opt-in 재시도/멱등성 키/`strictJson`/관측 훅 · 인증 모드(`getToken` bearer / `credentials`+`csrf` 쿠키) · 로컬 메시지 `locale`·opt-in `sendAcceptLanguage` |
28
+ | csrf | `csrfHeaderFor` · `readCookie` · `isUnsafeMethod` · `DEFAULT_CSRF_COOKIE_NAME` · `DEFAULT_CSRF_HEADER_NAME` · `CsrfOptions` | CSRF double-submit 헤더 계산 — 비안전 메서드 + 쿠키 존재 + 같은 출처/`allowedOrigins` 일 때만. SSR 안전 |
29
+ | sse | `parseSseFrame` · `readSseStream` · `parseChatPayload` · `readSseChatEvents` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | 채팅 프로필 SSE — 청크·멀티바이트 경계 안전, 모르는 키는 skip. `readSseChatEvents` 는 표준 파서 위 어댑터(CRLF·멀티라인 data 수용) |
30
+ | sseEvents | `createSseEventParser` · `parseSseEvents` · `readSseEvents` · `formatSseEvent` · `formatSseComment` · `SseEvent` · `SseEventParser` · `SseEventParserOptions` · `ReadSseEventsOptions` · `ReadSseEventsResult` · `FormatSseEventOptions` | 범용 SSE — WHATWG 표준 파서(`event`/`id`/`retry`, CRLF·CR, BOM)·스트림 리더(중단·`Last-Event-ID` 이어받기)·정규 빌더/하트비트 |
26
31
  | jwt | `decodeJwtPayload` · `getTokenExpiry` · `isTokenExpired` | base64url 디코드만 — **서명 미검증**, 만료 판단은 fail-closed |
27
32
  | masking | `maskSecret` · `maskName` · `maskPhone` · `maskEmail` · `maskCardNumber` | 시크릿(앞4+뒤4)·이름·휴대폰·이메일·카드번호 마스킹 |
28
33
  | datetime | `toWireDateTime` · `toWireDate` · `parseWireDateTime` · `stripZone` | 오프셋 없는 LocalDateTime 와이어 형식 — `toISOString()`(Z) 의 9시간 스큐 차단 |
@@ -37,7 +42,8 @@ npm i @rscc/common-core
37
42
  | idempotency | `generateIdempotencyKey` | 멱등성 키 생성 (apiClient `idempotency` 옵션으로 자동 부착 가능) |
38
43
  | webhook | `signWebhook` · `verifyWebhook` · `WEBHOOK_SIGNATURE_HEADER` | HMAC-SHA256 웹훅 서명/검증 (WebCrypto — async), 시크릿 로테이션 |
39
44
  | bulk | `isBulkResult` · `bulkFailures` · `createBulkResultBuilder` · `BulkResult` · `BulkResultItem` · `BulkResultBuilder` | 벌크/부분 실패 봉투 판별·생성 |
40
- | upload | `sniffFile` · `kindsForExtension` · `validateUpload` · `FileKind` · `UploadValidationResult` | 업로드 프리검증 — 크기·확장자·매직바이트 대조 |
45
+ | upload | `sniffFile` · `kindsForExtension` · `validateUpload` · `FileKind` · `UploadValidationResult` · `UploadMessageKey` | 업로드 프리검증 — 크기·확장자·매직바이트 대조, 메시지 `locale`·`messages` 오버라이드 |
46
+ | messages | `getMessage` · `formatMessage` · `negotiateLanguage` · `MESSAGES` · `FIXED_MESSAGE_KEYS` · `MessageKey` · `BuiltinLanguage` · `GetMessageOptions` · `MessageOverrides` | 와이어 메시지 다국어 — 내장 ko(기본)·en 카탈로그, 해석 순서·오버라이드, `{N}` 단일 패스 치환, `Accept-Language` 협상 |
41
47
  | query | `buildListQuery` · `ListQueryOptions` · `SortParam` | 목록 조회 쿼리(page/size/sort/필터) 빌더 |
42
48
  | featureFlags | `parseFlag` · `createFeatureFlags` · `FeatureFlagReader` | 피처 플래그 파싱 (참 집합 `true`/`1`/`on`/`yes`) |
43
49
  | rrn | `normalizeRrn` · `isValidRrn` · `isForeignerRrn` · `rrnChecksumOkLegacy` · `rrnBirthDate` | 주민등록번호 형식·생년월일 검증 (기본 검증은 **체크섬 미포함**) |
@@ -69,7 +75,7 @@ const user = await api.request<UserInfo>("/api/v1/users/me");
69
75
  // 문자열 body → Content-Type: application/json 자동 부착
70
76
  await api.request("/api/v1/items", { method: "POST", body: JSON.stringify(item) });
71
77
 
72
- // 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId, data, retryAfterMs }
78
+ // 실패 봉투/HTTP 에러 → ApiError { code, commonCode, message, status, traceId, data, retryAfterMs }
73
79
  try {
74
80
  await api.request("/api/v1/things/999");
75
81
  } catch (e) {
@@ -95,6 +101,95 @@ const { data, traceId, response } = await api.requestWithMeta<UserInfo>("/api/v1
95
101
  - `requestWithMeta` 의 `response` 는 **본문 미소비 원본** — 파싱은 `clone()` 사본으로 하므로 `text()`/`json()`
96
102
  재호출이 가능하다.
97
103
 
104
+ ### 에러 코드 — 공통 코드 + 서비스 도메인 코드 폴백
105
+
106
+ 봉투의 `code` 는 공통 코드 10종(`"200"`·`"400"`·`"401"`·`"403"`·`"404"`·`"405"`·`"409"`·`"415"`·`"429"`·`"500"`)
107
+ 또는 서비스가 얹은 **도메인 코드**(예: `ORDER_OUT_OF_STOCK` — 실패 전용, 정확히 3자리 숫자는 공통 예약)다.
108
+ 모르는 코드를 받으면 오류를 내지 말고 **HTTP 상태로 공통 코드를 유도**한다 — 이 폴백 덕분에 서버가 도메인 코드를
109
+ 추가해도 기존 소비자는 깨지지 않는다.
110
+
111
+ ```ts
112
+ import { ApiError, ResultCode, resultCodeForStatus, toCommonResultCode, isCommonResultCode } from "@rscc/common-core";
113
+
114
+ try {
115
+ await api.request("/api/v1/payments", { method: "POST", body: JSON.stringify(payment) });
116
+ } catch (e) {
117
+ if (!(e instanceof ApiError)) throw e;
118
+ // 도메인별 처리는 원 코드로
119
+ if (e.code === "PAYMENT_DECLINED") return showDeclined();
120
+ // 공통 분기는 정규화된 commonCode 로 — HTTP 402 + PAYMENT_DECLINED → "400"
121
+ switch (e.commonCode) {
122
+ case ResultCode.UNAUTHORIZED: return redirectToLogin();
123
+ case ResultCode.BAD_REQUEST: return showInputError(e.message);
124
+ default: return showGenericError(e.traceId);
125
+ }
126
+ }
127
+
128
+ resultCodeForStatus(404); // "404" — 정확 일치 공통 코드 (SUCCESS 제외)
129
+ resultCodeForStatus(422); // "400" — 그 외 4xx
130
+ resultCodeForStatus(503); // "500" — 그 외
131
+ toCommonResultCode("PAYMENT_DECLINED", 402); // "400" — 모르는 코드는 상태로 유도
132
+ toCommonResultCode("409", 400); // "409" — 공통 코드는 그대로
133
+ isCommonResultCode("ORDER_OUT_OF_STOCK"); // false (타입 가드: true 면 ResultCode)
134
+ ```
135
+
136
+ - `ApiError.code` 는 원 코드 보존(비봉투 에러는 상태 코드 문자열, `strictJson` 은 `"INVALID_JSON"`),
137
+ `ApiError.commonCode` 는 생성자에서 `toCommonResultCode(code, status)` 로 계산한 공통 코드다.
138
+ - `CommonResponse.code` / `ApiError.code` 의 타입 `ErrorCodeValue` 는 `ResultCode | (string & {})` —
139
+ `string` 과 상호 대입 가능하면서 공통 코드 리터럴을 자동완성한다.
140
+
141
+ ### 인증 모드 — bearer vs 쿠키(jwt-cookie / session) + CSRF
142
+
143
+ 서버가 인증 자격을 어디로 받는지(서버 설정의 인증 모드)에 맞춰 클라이언트 설정을 고른다.
144
+
145
+ | 서버 모드 | 자격 운반 | 클라이언트 설정 |
146
+ |---|---|---|
147
+ | `bearer` (기본) | `Authorization: Bearer <JWT>` | `getToken` |
148
+ | `jwt-cookie` | HttpOnly 쿠키 (JS 가 토큰을 못 봄) | `credentials` + `csrf` |
149
+ | `session` | 서버 세션 쿠키 | `credentials` + `csrf` |
150
+
151
+ ```ts
152
+ // bearer — 토큰을 앱이 보관하고 헤더로 싣는다 (앱·서버 간 호출, 모바일 등)
153
+ const bearerApi = createApiClient({ baseUrl: "https://api.example.com", getToken: () => tokenStore.get() });
154
+
155
+ // jwt-cookie / session — 같은 출처(리버스 프록시로 프론트와 API 를 한 오리진에, 권장)
156
+ const api = createApiClient({ baseUrl: "/api", credentials: "same-origin", csrf: true });
157
+
158
+ // jwt-cookie / session — 같은 사이트 서브도메인 (app.example.com → api.example.com)
159
+ const subdomainApi = createApiClient({
160
+ baseUrl: "https://api.example.com",
161
+ credentials: "include", // 교차 출처로 쿠키 전송
162
+ csrf: { allowedOrigins: ["https://api.example.com"] }, // 이 오리진에만 CSRF 헤더 허용
163
+ });
164
+
165
+ // 로그인 상태 확인은 토큰 만료 계산(isTokenExpired) 대신 서버 엔드포인트로
166
+ const me = await api.request<UserInfo>("/api/v1/users/me").catch(() => null);
167
+ ```
168
+
169
+ - **`credentials`**: fetch `credentials` 기본값. 미지정이면 속성 자체를 넘기지 않는다(fetch 기본 `"same-origin"`).
170
+ 호출별 `init.credentials` 가 우선한다.
171
+ - **`csrf`** (double-submit): 서버가 내려준 HttpOnly 아닌 쿠키 `XSRF-TOKEN` 값을 `X-XSRF-TOKEN` 헤더로 되돌려 보낸다.
172
+ **비안전 메서드**(GET·HEAD·OPTIONS·TRACE 외)이고 쿠키가 있으며 요청 URL 이 **같은 출처**(상대 URL 포함)이거나
173
+ `allowedOrigins` 에 속할 때만 붙인다 — 교차 출처로는 보내지 않는다(토큰 유출 방지). 호출자가 이미 실은 헤더는
174
+ 덮어쓰지 않고, 재시도마다 쿠키를 다시 읽는다(토큰 회전 대응).
175
+ - `CsrfOptions`: `cookieName`(기본 `XSRF-TOKEN`) · `headerName`(기본 `X-XSRF-TOKEN`) · `allowedOrigins`(오리진 정규화 비교,
176
+ 와일드카드 없음) · `readCookie`(원시 쿠키 문자열 공급자 — `document` 가 없는 React Native·테스트용).
177
+ - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않고 오류도 내지 않는다.
178
+ 모듈 로드 시점에 `document`/`location` 에 접근하지 않는다.
179
+ - 쿠키 모드의 웹 클라이언트는 **토큰을 localStorage/sessionStorage 에 저장하지 않는다** — 웹이 다시 헤더 토큰을 쓰면
180
+ HttpOnly 쿠키로 막은 XSS 토큰 탈취 경로가 되살아난다(`getToken` 은 앱·서버 간 호출용).
181
+ - 서브도메인 토폴로지는 서버 쪽도 맞춰야 한다 — CSRF 쿠키 `Domain` 을 상위 도메인으로(프론트 JS 가 읽도록), CORS 가
182
+ 자격 허용(`Access-Control-Allow-Credentials: true` + 명시 오리진)과 `X-XSRF-TOKEN` 요청 헤더를 허용.
183
+
184
+ 헬퍼를 직접 쓸 수도 있다 (axios 인터셉터 등 다른 HTTP 클라이언트 — 서버는 CSRF 토큰을 헤더로만 받는다):
185
+
186
+ ```ts
187
+ import { csrfHeaderFor, readCookie } from "@rscc/common-core";
188
+
189
+ csrfHeaderFor("/api/v1/items", "POST", true); // ["X-XSRF-TOKEN", "<쿠키 값>"] | null
190
+ readCookie("XSRF-TOKEN"); // document.cookie 에서 첫 값 (디코딩 안 함) | null
191
+ ```
192
+
98
193
  ### 서킷 브레이커 — execute 권장
99
194
 
100
195
  ```ts
@@ -113,21 +208,121 @@ await retry(() => cb.execute(() => api.request("/api/v1/downstream")), {
113
208
  - 보고가 끝내 오지 않은 그랜트는 OPEN 진입 후 `2 × openDurationMs` 에 유실로 용서되어 OPEN 영구 고착을 막는다.
114
209
  HALF_OPEN 프로브도 `openDurationMs` 동안 미보고면 유실로 간주해 새 프로브를 넘긴다(HALF_OPEN 고착 방지).
115
210
 
116
- ### SSE 스트림 소비
211
+ ### SSE 스트림 소비 — 채팅 프로필
117
212
 
118
213
  ```ts
119
- import { readSseStream } from "@rscc/common-core";
214
+ import { readSseStream, readSseChatEvents } from "@rscc/common-core";
120
215
 
121
216
  const res = await fetch(streamUrl, { method: "POST", body, signal });
122
- await readSseStream(res, {
217
+ await readSseStream(res, { // 레거시 리더 — LF(\n\n) 프레임 구분, 첫 data: 라인
123
218
  onConversationId: (id) => { /* 첫 프레임 */ },
124
219
  onSources: (sources) => { /* RAG 근거 */ },
125
220
  onDelta: (chunk) => { /* 텍스트 증분 */ },
126
- onError: (message) => { /* in-band 오류 */ },
221
+ onError: (message) => { /* in-band 오류 — 이후 delta 는 전달 안 됨 */ },
127
222
  onDone: () => { /* data: [DONE] */ },
128
223
  });
224
+
225
+ // 같은 콜백, 표준 파서 위의 어댑터 — CRLF·CR 줄 끝, 멀티라인 data, "[DONE] "(공백) 도 수용.
226
+ // EOF 에서 빈 줄 없이 끝난 잔여 프레임도 레거시처럼 처리한다.
227
+ await readSseChatEvents(res, callbacks);
228
+ ```
229
+
230
+ ### SSE 범용 계층 — 표준 파서·리더·빌더 (WHATWG event stream)
231
+
232
+ 채팅 외의 SSE(알림·진행률·로그 등)와 `event`/`id`/`retry` 필드를 쓰는 스트림용. WHATWG HTML 표준의 이벤트 스트림
233
+ 해석을 그대로 따른다 — BOM 1개 제거, CRLF·LF·CR 줄 끝(청크 경계의 CR+LF 도 1개), 값 앞 공백 1개만 제거,
234
+ NUL 포함 `id` 무시, `retry` 는 ASCII 숫자만, 데이터 없는 블록은 디스패치 안 함, **EOF 의 미완성 이벤트는 폐기**.
235
+
236
+ ```ts
237
+ import {
238
+ readSseEvents, parseSseEvents, createSseEventParser, formatSseEvent, formatSseComment, type SseEvent,
239
+ } from "@rscc/common-core";
240
+
241
+ // 1) fetch 스트림 읽기 — onEvent 가 true 를 반환하면 즉시 멈추고 연결(reader)을 cancel
242
+ const res = await fetch("/api/v1/jobs/42/events", { signal });
243
+ const { lastEventId, retryMs, stopped } = await readSseEvents(res, {
244
+ onEvent: (e: SseEvent) => { // { event: "progress", data: "60", id: "17" }
245
+ render(e);
246
+ return e.event === "end"; // 종료 이벤트 → stopped: true
247
+ },
248
+ onRetry: (ms) => { /* 서버 재연결 대기 힌트 */ },
249
+ // lastEventId: prevId, // 재연결 시 직전 값 — id 없는 이벤트도 이어받음(EventSource 동일)
250
+ });
251
+
252
+ // 2) 완결 텍스트 파싱 / 증분 파서 직접 구동
253
+ parseSseEvents('event: update\nid: 42\ndata: {"x":1}\n\n'); // [{ event: "update", data: '{"x":1}', id: "42" }]
254
+ const parser = createSseEventParser((e) => handle(e), (ms) => setRetry(ms));
255
+ parser.feed(chunk1);
256
+ parser.feed(chunk2);
257
+ parser.end(); // 미완성 이벤트 폐기 — end({ dispatchIncomplete: true }) 면 잔여까지 디스패치
258
+
259
+ // 3) 서버/프록시 측 정규 빌더 — 필드 순서 id → event → retry → data, 줄 끝 LF
260
+ formatSseEvent("a\nb", { id: "7", event: "update", retry: 3000 });
261
+ // "id: 7\nevent: update\nretry: 3000\ndata: a\ndata: b\n\n"
262
+ formatSseComment("ping"); // ": ping\n\n" — 하트비트(이벤트 없음)
129
263
  ```
130
264
 
265
+ - `readSseEvents` 는 body 가 없으면 throw 하고, `onEvent`/`onRetry` 가 throw 하면 reader 를 cancel 한 뒤 그 오류로 reject 한다.
266
+ - 빌더는 `id`/`event` 의 CR·LF, `id` 의 NUL, 음수·비정수 `retry` 를 오류로 거부한다. `id: ""` 는 `id: ` 를 써서
267
+ 수신 측의 마지막 이벤트 ID 를 초기화한다. 빈 `event` 는 생략(수신 측 기본 `"message"` 와 동치).
268
+ - 권장 응답 헤더: `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`.
269
+ - React 에서는 `@rscc/common-react` 의 `useSseEvents`(재연결 시 `Last-Event-ID`·서버 `retry:` 존중)를 쓴다.
270
+
271
+ ### 메시지 다국어 — 내장 ko(기본)·en
272
+
273
+ 라이브러리가 내보내는 와이어 메시지(서버 봉투 메시지 22키 + 클라이언트 로컬 메시지 3키)를 **ko·en 으로 내장**한다.
274
+ **기본 로케일은 `ko`** — 아무것도 지정하지 않으면 모든 문구는 이전(현행 한국어)과 바이트 동일하다. `code` 는
275
+ 로케일과 무관하게 불변이고 `message` 만 바뀐다.
276
+
277
+ ```ts
278
+ import { getMessage, formatMessage, negotiateLanguage, MESSAGES, FIXED_MESSAGE_KEYS } from "@rscc/common-core";
279
+
280
+ getMessage("rscc.result.NOT_FOUND"); // "리소스를 찾을 수 없습니다." (기본 ko)
281
+ getMessage("rscc.result.NOT_FOUND", { locale: "en-GB" }); // "Resource not found." — BCP 47 주 서브태그 사용
282
+ getMessage("rscc.upload.extension", { locale: "en", args: ["exe"] }); // "File type is not allowed: exe"
283
+ getMessage("rscc.result.NOT_FOUND", { locale: "ja" }); // 내장 밖 언어 → 기본 언어(defaultLocale, 기본 ko) 표
284
+ getMessage("rscc.result.NOT_FOUND", {
285
+ locale: "ja",
286
+ overrides: (key, language) => (language === "ja" ? jaCatalog[key] : undefined), // 또는 { [key]: 템플릿 } 표
287
+ });
288
+ formatMessage("{0}-{1}", "{1}", "x"); // "{1}-x" — 단일 패스 치환(재치환 없음)
289
+
290
+ // Accept-Language 협상 — 서버와 같은 규칙 (q 내림차순 안정 정렬, q=0 거부, `*`·미지원·형식 오류 → 기본 언어)
291
+ negotiateLanguage("en-US,en;q=0.9,ko;q=0.8"); // "en"
292
+ negotiateLanguage("ja", ["ko", "en", "ja"]); // "ja" — 지원 언어 추가
293
+ negotiateLanguage(undefined, ["ko", "en"], "en"); // "en" — 헤더 없음 → 기본 언어
294
+ ```
295
+
296
+ - **해석 순서**: ① 고정 키 + 요청 언어 `ko`·`en` → 내장 → ② `overrides`(빈 값·키 자체와 같은 결과는 미해결) →
297
+ ③ 내장[요청 언어] → ④ 내장[`defaultLocale`] → ⑤ 내장[`ko`] → ⑥ 키 문자열 (모르는 키도 예외 없음).
298
+ - **규약 고정 문구**(`FIXED_MESSAGE_KEYS` — 멱등성 키 3종·정렬 쿼리 2종·CSRF)는 클라이언트가 문구로 분기할 수 있어
299
+ `ko`·`en` 은 오버라이드로 바꿀 수 없다(그 외 언어는 허용). 서버가 영어를 켜면 이 문구도 `en` 값으로 오므로, 문구로
300
+ 분기하는 클라이언트는 `MESSAGES.en[key]` 도 함께 인식할 것 (가능하면 `code` 로 분기).
301
+ - 자리표시자 `{0}`,`{1}`… 는 단일 패스 문자 치환(MessageFormat 아님) — 인자가 없는 `{N}` 은 그대로 남는다.
302
+ - **서버 봉투의 `message` 는 서버가 정한다** — 서비스가 i18n 을 켜면(기본 로케일 변경 또는 `Accept-Language` 요청별
303
+ 협상) 서버가 현지화된 문구를 보낸다. 클라이언트의 `locale` 은 서버 문구를 바꾸지 않는다.
304
+
305
+ 클라이언트가 직접 만드는 문구:
306
+
307
+ ```ts
308
+ // 업로드 프리검증 — locale 미지정 = 현행 ko 문구
309
+ validateUpload({ fileName, size, head, maxSizeBytes, allowedExtensions, locale: "en" });
310
+ // → { ok: false, reason: "extension", message: "File type is not allowed: exe" }
311
+ validateUpload({ ...input, messages: { "rscc.upload.size": "10MB 이하만 올릴 수 있어요." } }); // 템플릿 오버라이드
312
+
313
+ // apiClient — 로컬 ApiError 문구 + opt-in Accept-Language
314
+ const api = createApiClient({ baseUrl, locale: "en", sendAcceptLanguage: true });
315
+ // 비봉투 HTTP 에러 → "API error: 503 Service Unavailable" · strictJson → "The response body is not valid JSON."
316
+ ```
317
+
318
+ - `validateUpload` 의 `messages`: `rscc.upload.size` / `rscc.upload.extension`(`{0}` = 확장자) / `rscc.upload.contentMismatch`
319
+ 템플릿 — 준 키는 로케일과 무관하게 내장 문구보다 우선.
320
+ - `ApiClientConfig.locale` 은 클라이언트가 만드는 3종(본문 읽기 실패·`INVALID_JSON`·비봉투 HTTP 에러)에만 적용 —
321
+ 실패 봉투의 `message` 는 서버 문구 그대로.
322
+ - **`sendAcceptLanguage`** (기본 false): true 이고 `locale` 이 있으면 `Accept-Language: <locale>` 부착(호출자 헤더 우선).
323
+ **브라우저는 사용자 언어 설정으로 `Accept-Language` 를 스스로 보낸다** — 이 옵션은 그 헤더가 없는 Node·SSR 이나
324
+ 언어를 강제할 때 쓴다. 값은 BCP 47 태그로 둘 것(CORS safelisted — preflight 미유발).
325
+
131
326
  ### AES-256/GCM — Node 전용 서브패스
132
327
 
133
328
  ```ts
@@ -139,19 +334,29 @@ const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
139
334
 
140
335
  ## 알려진 제약
141
336
 
142
- - **SSE 파서는 프레임 구분자 LF(`\n\n`) 고정** — 개행을 CRLF 로 정규화하는 프록시 뒤에서는 프레임이 분리되지 않는다.
337
+ - **레거시 SSE 리더(`parseSseFrame`/`readSseStream`)는 프레임 구분자 LF(`\n\n`) 고정** — 개행을 CRLF 로 정규화하는
338
+ 프록시 뒤에서는 프레임이 분리되지 않는다. 이 경우 `readSseChatEvents`(표준 파서 위 채팅 어댑터)를 쓸 것.
339
+ - 표준 SSE 리더(`readSseEvents`)는 스펙대로 **EOF 의 미완성 이벤트를 폐기**한다 — 서버는 이벤트를 빈 줄로 끝내야 한다
340
+ (레거시 호환이 필요하면 `dispatchIncompleteAtEof: true`).
143
341
  - 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 기본적으로 조용히 null 이 된다 — 명시 실패가 필요하면 `strictJson: true`.
144
342
  - 문자열 body 는 `application/json` 으로 간주된다 — JSON 이 아닌 문자열 본문은 Content-Type 을 직접 지정할 것.
145
343
  - 교차 출처에서 traceId 에코를 읽으려면 서버가 `Access-Control-Expose-Headers: X-Trace-Id` 를 내려야 한다
146
344
  (못 읽으면 발신 값을 노출 — 서버가 수신 traceId 를 재사용하므로 동일 값).
147
345
  - 1회성 body(`ReadableStream`/`FormData`)는 재전송이 불가해 `retry` 를 지정해도 1회만 실행된다.
148
346
  - JWT 디코더는 **서명을 검증하지 않는다** — 표시·만료 판단 전용. 인가 판단은 반드시 서버에서.
347
+ 쿠키 인증 모드(HttpOnly 쿠키)에서는 JS 가 토큰을 볼 수 없으므로 `/me` 같은 서버 엔드포인트로 상태를 확인할 것.
348
+ - `csrf` 는 교차 출처 요청에 헤더를 붙이지 않는다 — 서브도메인 API 는 `allowedOrigins` 에 명시해야 한다.
349
+ CSRF 쿠키를 읽으려면 그 쿠키가 프론트 페이지 도메인에서 보여야 한다(서버 `Domain` 설정).
149
350
  - `isValidRrn` 은 체크섬을 포함하지 않는다(2020-10 이후 발급분은 체크섬 불성립) — `rrnChecksumOkLegacy` 는 레거시 정합 검사 전용.
150
351
  - `signWebhook`/`verifyWebhook` 은 WebCrypto 기반이라 **async** 다 (Java/Python 은 동기).
151
352
  - 서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시는 **인스턴스(프로세스) 로컬** 상태다 — 분산 공유되지 않는다.
353
+ - 내장 메시지 언어는 `ko`·`en` 뿐이다 — 그 외 언어는 `getMessage` 의 `overrides`(업로드는 `messages`)로 공급할 것.
152
354
  - `@rscc/common-core/crypto` 는 `node:crypto` 를 쓰는 **Node 전용** 서브패스 — 브라우저 번들에 포함하지 말 것.
355
+ - 서브패스는 `exports` 맵으로만 노출된다 — TypeScript `moduleResolution` 이 레거시 `node`(`node10`)면
356
+ `@rscc/common-core/crypto` 의 타입을 찾지 못한다. `node16` / `nodenext` / `bundler` 를 사용할 것
357
+ (메인 엔트리 `@rscc/common-core` 는 모든 모드에서 해석된다).
153
358
 
154
359
  ## 관련 패키지
155
360
 
156
- - React 훅(`useDebounce`, `useSse`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
361
+ - React 훅(`useDebounce`, `useSse`, `useSseEvents`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
157
362
  - 라이선스: MIT