@rscc/common-core 0.4.0 → 0.6.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 +205 -14
- package/dist/index.cjs +617 -36
- package/dist/index.d.cts +425 -19
- package/dist/index.d.ts +425 -19
- package/dist/index.js +602 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# @rscc/common-core
|
|
2
2
|
|
|
3
|
-
RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키·쿠키 인증/CSRF),
|
|
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
|
|
|
@@ -20,13 +22,15 @@ 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
|
-
|
|
|
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` |
|
|
25
28
|
| 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` |
|
|
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` 이어받기)·정규 빌더/하트비트 |
|
|
27
31
|
| jwt | `decodeJwtPayload` · `getTokenExpiry` · `isTokenExpired` | base64url 디코드만 — **서명 미검증**, 만료 판단은 fail-closed |
|
|
28
32
|
| masking | `maskSecret` · `maskName` · `maskPhone` · `maskEmail` · `maskCardNumber` | 시크릿(앞4+뒤4)·이름·휴대폰·이메일·카드번호 마스킹 |
|
|
29
|
-
| datetime | `toWireDateTime` · `toWireDate` · `parseWireDateTime` · `stripZone` | 오프셋 없는 LocalDateTime 와이어 형식 — `toISOString()`(Z) 의 9시간 스큐
|
|
33
|
+
| datetime | `toWireDateTime` · `toWireDate` · `parseWireDateTime` · `stripZone` · `WireDateTimeFormatOptions` · `WireDateFormatOptions` · `WireDateTimeParseOptions` · `InboundOffsetPolicy` | 오프셋 없는 LocalDateTime 와이어 형식(기본) — `toISOString()`(Z) 의 9시간 스큐 차단. opt-in 기준 시간대(`timeZone`)·offset 프로필(`offset`)·수신 오프셋 정책(`inboundOffset`) |
|
|
30
34
|
| chosung | `toChosung` · `isChosungQuery` | 한글 초성 변환·초성 질의 판별 (자동완성) |
|
|
31
35
|
| retry | `retry` · `isRetryableStatus` · `parseRetryAfterMs` · `RetryOptions` | 지수 백오프 + full jitter, 시간 예산, `Retry-After` 파싱 |
|
|
32
36
|
| cache | `createTtlCache` · `TtlCache<K,V>` · `TtlCacheOptions` | TTL 캐시 + single-flight(동일 키 로더 1회 공유), lazy expiry |
|
|
@@ -38,7 +42,8 @@ npm i @rscc/common-core
|
|
|
38
42
|
| idempotency | `generateIdempotencyKey` | 멱등성 키 생성 (apiClient `idempotency` 옵션으로 자동 부착 가능) |
|
|
39
43
|
| webhook | `signWebhook` · `verifyWebhook` · `WEBHOOK_SIGNATURE_HEADER` | HMAC-SHA256 웹훅 서명/검증 (WebCrypto — async), 시크릿 로테이션 |
|
|
40
44
|
| bulk | `isBulkResult` · `bulkFailures` · `createBulkResultBuilder` · `BulkResult` · `BulkResultItem` · `BulkResultBuilder` | 벌크/부분 실패 봉투 판별·생성 |
|
|
41
|
-
| 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` 협상 |
|
|
42
47
|
| query | `buildListQuery` · `ListQueryOptions` · `SortParam` | 목록 조회 쿼리(page/size/sort/필터) 빌더 |
|
|
43
48
|
| featureFlags | `parseFlag` · `createFeatureFlags` · `FeatureFlagReader` | 피처 플래그 파싱 (참 집합 `true`/`1`/`on`/`yes`) |
|
|
44
49
|
| rrn | `normalizeRrn` · `isValidRrn` · `isForeignerRrn` · `rrnChecksumOkLegacy` · `rrnBirthDate` | 주민등록번호 형식·생년월일 검증 (기본 검증은 **체크섬 미포함**) |
|
|
@@ -70,7 +75,7 @@ const user = await api.request<UserInfo>("/api/v1/users/me");
|
|
|
70
75
|
// 문자열 body → Content-Type: application/json 자동 부착
|
|
71
76
|
await api.request("/api/v1/items", { method: "POST", body: JSON.stringify(item) });
|
|
72
77
|
|
|
73
|
-
// 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId, data, retryAfterMs }
|
|
78
|
+
// 실패 봉투/HTTP 에러 → ApiError { code, commonCode, message, status, traceId, data, retryAfterMs }
|
|
74
79
|
try {
|
|
75
80
|
await api.request("/api/v1/things/999");
|
|
76
81
|
} catch (e) {
|
|
@@ -96,6 +101,43 @@ const { data, traceId, response } = await api.requestWithMeta<UserInfo>("/api/v1
|
|
|
96
101
|
- `requestWithMeta` 의 `response` 는 **본문 미소비 원본** — 파싱은 `clone()` 사본으로 하므로 `text()`/`json()`
|
|
97
102
|
재호출이 가능하다.
|
|
98
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
|
+
|
|
99
141
|
### 인증 모드 — bearer vs 쿠키(jwt-cookie / session) + CSRF
|
|
100
142
|
|
|
101
143
|
서버가 인증 자격을 어디로 받는지(서버 설정의 인증 모드)에 맞춰 클라이언트 설정을 고른다.
|
|
@@ -166,21 +208,164 @@ await retry(() => cb.execute(() => api.request("/api/v1/downstream")), {
|
|
|
166
208
|
- 보고가 끝내 오지 않은 그랜트는 OPEN 진입 후 `2 × openDurationMs` 에 유실로 용서되어 OPEN 영구 고착을 막는다.
|
|
167
209
|
HALF_OPEN 프로브도 `openDurationMs` 동안 미보고면 유실로 간주해 새 프로브를 넘긴다(HALF_OPEN 고착 방지).
|
|
168
210
|
|
|
169
|
-
### SSE 스트림 소비
|
|
211
|
+
### SSE 스트림 소비 — 채팅 프로필
|
|
170
212
|
|
|
171
213
|
```ts
|
|
172
|
-
import { readSseStream } from "@rscc/common-core";
|
|
214
|
+
import { readSseStream, readSseChatEvents } from "@rscc/common-core";
|
|
173
215
|
|
|
174
216
|
const res = await fetch(streamUrl, { method: "POST", body, signal });
|
|
175
|
-
await readSseStream(res, {
|
|
217
|
+
await readSseStream(res, { // 레거시 리더 — LF(\n\n) 프레임 구분, 첫 data: 라인
|
|
176
218
|
onConversationId: (id) => { /* 첫 프레임 */ },
|
|
177
219
|
onSources: (sources) => { /* RAG 근거 */ },
|
|
178
220
|
onDelta: (chunk) => { /* 텍스트 증분 */ },
|
|
179
|
-
onError: (message) => { /* in-band 오류 */ },
|
|
221
|
+
onError: (message) => { /* in-band 오류 — 이후 delta 는 전달 안 됨 */ },
|
|
180
222
|
onDone: () => { /* data: [DONE] */ },
|
|
181
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" — 하트비트(이벤트 없음)
|
|
182
263
|
```
|
|
183
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
|
+
### 날짜·시각 — 기준 시간대 · offset 프로필 (opt-in)
|
|
272
|
+
|
|
273
|
+
**기본은 local 프로필** — 옵션을 주지 않으면 현행 그대로(실행 환경 로컬 게터, 오프셋 없는 `yyyy-MM-ddTHH:mm:ss`,
|
|
274
|
+
수신 오프셋은 버림). 서비스 단위로 기준 시간대와 offset 프로필을 켤 수 있다.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { toWireDateTime, toWireDate, parseWireDateTime } from "@rscc/common-core";
|
|
278
|
+
|
|
279
|
+
const at = new Date("2026-07-10T04:00:00Z");
|
|
280
|
+
toWireDateTime(at); // 로컬 게터 (기본·현행)
|
|
281
|
+
toWireDateTime(at, { timeZone: "Asia/Seoul" }); // "2026-07-10T13:00:00" (DT-01)
|
|
282
|
+
toWireDateTime(at, { timeZone: "Asia/Seoul", offset: true }); // "2026-07-10T13:00:00+09:00" (DT-02)
|
|
283
|
+
toWireDateTime(at, { timeZone: "UTC", offset: true }); // "2026-07-10T04:00:00Z" (DT-03 — 0 은 Z)
|
|
284
|
+
toWireDateTime(at, { offset: true }); // 호스트 오프셋(-getTimezoneOffset) 부착
|
|
285
|
+
toWireDate(at, { timeZone: "Asia/Seoul" }); // "2026-07-10"
|
|
286
|
+
|
|
287
|
+
// 수신 — 오프셋 없는 벽시계를 기준 시간대로 해석 (DST 갭은 갭 길이만큼 뒤로, 겹침은 이른 오프셋)
|
|
288
|
+
parseWireDateTime("2026-07-10T13:00:00", { timeZone: "Asia/Seoul" }); // 04:00Z (DT-06)
|
|
289
|
+
parseWireDateTime("2026-03-08T02:30:00", { timeZone: "America/New_York" }); // 07:30Z (DT-12 갭)
|
|
290
|
+
parseWireDateTime("2026-11-01T01:30:00", { timeZone: "America/New_York" }); // 05:30Z (DT-13 겹침)
|
|
291
|
+
|
|
292
|
+
// 수신 오프셋 정책 — 오프셋이 붙은 입력
|
|
293
|
+
parseWireDateTime("2026-07-10T04:00:00Z", { inboundOffset: "convert" }); // 정확한 순간 04:00Z (timeZone 무관)
|
|
294
|
+
parseWireDateTime("2026-07-10T04:00:00Z", { timeZone: "Asia/Seoul" }); // drop(기본) — 벽시계 04:00 KST (현행 스큐)
|
|
295
|
+
parseWireDateTime("2026-07-10T04:00:00Z", { inboundOffset: "reject" }); // RangeError
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
| 옵션 | 함수 | 기본 | 설명 |
|
|
299
|
+
|---|---|---|---|
|
|
300
|
+
| `timeZone` | 세 함수 모두 | 실행 환경 로컬 | IANA 이름(`Asia/Seoul`·`UTC`·`America/New_York`). 잘못된 이름은 `Intl.DateTimeFormat` 의 `RangeError` |
|
|
301
|
+
| `offset` | `toWireDateTime` | `false` | offset 프로필 — 그 순간의 기준 시간대 오프셋(`Z` / `±HH:MM`) 부착 |
|
|
302
|
+
| `inboundOffset` | `parseWireDateTime` | `"drop"` | `drop` 오프셋 버리고 벽시계(현행) · `convert` 오프셋으로 정확한 순간 · `reject` `RangeError` |
|
|
303
|
+
|
|
304
|
+
- **프로필 전환 순서**: offset 프로필로 바꾸는 것은 그 서비스 API 에 breaking 이다(현행 Jackson `LocalDateTime` 리더는
|
|
305
|
+
`+09:00` 을 거부). **클라이언트가 먼저 `inboundOffset: "convert"` 를 켜고 배포**한 뒤 서버가 offset 프로필로 바꾼다.
|
|
306
|
+
클라이언트 발신에 `offset: true` 를 켜는 것도 서버 전환 이후다.
|
|
307
|
+
- `convert`·`reject` 는 RFC 3339 오프셋(`Z`·`±HH:MM`)만 인식한다 — `+0900` 같은 표기는 형식 오류(`drop` 은 현행대로 버림).
|
|
308
|
+
오프셋 없는 입력은 모든 정책에서 벽시계 그대로.
|
|
309
|
+
- 발신은 초 단위(밀리초 절단 — 현행), 수신 소수부는 밀리초까지 보존(`.123456789` → `.123`, DT-14).
|
|
310
|
+
- 시간대 계산은 `Intl.DateTimeFormat(...).formatToParts` 만 쓴다(의존성 0, 시간대별 포매터 캐시) — Intl 의 IANA 시간대
|
|
311
|
+
지원이 필요하다(Node 20+ 공식 빌드(full-ICU)·모던 브라우저는 기본 포함).
|
|
312
|
+
- 골든 벡터 DT-01~14(contracts/datetime.md)는 `timeZone` 을 명시해 실행 머신의 시간대와 무관하게 검증한다.
|
|
313
|
+
|
|
314
|
+
### 메시지 다국어 — 내장 ko(기본)·en
|
|
315
|
+
|
|
316
|
+
라이브러리가 내보내는 와이어 메시지(서버 봉투 메시지 22키 + 클라이언트 로컬 메시지 3키)를 **ko·en 으로 내장**한다.
|
|
317
|
+
**기본 로케일은 `ko`** — 아무것도 지정하지 않으면 모든 문구는 이전(현행 한국어)과 바이트 동일하다. `code` 는
|
|
318
|
+
로케일과 무관하게 불변이고 `message` 만 바뀐다.
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { getMessage, formatMessage, negotiateLanguage, MESSAGES, FIXED_MESSAGE_KEYS } from "@rscc/common-core";
|
|
322
|
+
|
|
323
|
+
getMessage("rscc.result.NOT_FOUND"); // "리소스를 찾을 수 없습니다." (기본 ko)
|
|
324
|
+
getMessage("rscc.result.NOT_FOUND", { locale: "en-GB" }); // "Resource not found." — BCP 47 주 서브태그 사용
|
|
325
|
+
getMessage("rscc.upload.extension", { locale: "en", args: ["exe"] }); // "File type is not allowed: exe"
|
|
326
|
+
getMessage("rscc.result.NOT_FOUND", { locale: "ja" }); // 내장 밖 언어 → 기본 언어(defaultLocale, 기본 ko) 표
|
|
327
|
+
getMessage("rscc.result.NOT_FOUND", {
|
|
328
|
+
locale: "ja",
|
|
329
|
+
overrides: (key, language) => (language === "ja" ? jaCatalog[key] : undefined), // 또는 { [key]: 템플릿 } 표
|
|
330
|
+
});
|
|
331
|
+
formatMessage("{0}-{1}", "{1}", "x"); // "{1}-x" — 단일 패스 치환(재치환 없음)
|
|
332
|
+
|
|
333
|
+
// Accept-Language 협상 — 서버와 같은 규칙 (q 내림차순 안정 정렬, q=0 거부, `*`·미지원·형식 오류 → 기본 언어)
|
|
334
|
+
negotiateLanguage("en-US,en;q=0.9,ko;q=0.8"); // "en"
|
|
335
|
+
negotiateLanguage("ja", ["ko", "en", "ja"]); // "ja" — 지원 언어 추가
|
|
336
|
+
negotiateLanguage(undefined, ["ko", "en"], "en"); // "en" — 헤더 없음 → 기본 언어
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
- **해석 순서**: ① 고정 키 + 요청 언어 `ko`·`en` → 내장 → ② `overrides`(빈 값·키 자체와 같은 결과는 미해결) →
|
|
340
|
+
③ 내장[요청 언어] → ④ 내장[`defaultLocale`] → ⑤ 내장[`ko`] → ⑥ 키 문자열 (모르는 키도 예외 없음).
|
|
341
|
+
- **규약 고정 문구**(`FIXED_MESSAGE_KEYS` — 멱등성 키 3종·정렬 쿼리 2종·CSRF)는 클라이언트가 문구로 분기할 수 있어
|
|
342
|
+
`ko`·`en` 은 오버라이드로 바꿀 수 없다(그 외 언어는 허용). 서버가 영어를 켜면 이 문구도 `en` 값으로 오므로, 문구로
|
|
343
|
+
분기하는 클라이언트는 `MESSAGES.en[key]` 도 함께 인식할 것 (가능하면 `code` 로 분기).
|
|
344
|
+
- 자리표시자 `{0}`,`{1}`… 는 단일 패스 문자 치환(MessageFormat 아님) — 인자가 없는 `{N}` 은 그대로 남는다.
|
|
345
|
+
- **서버 봉투의 `message` 는 서버가 정한다** — 서비스가 i18n 을 켜면(기본 로케일 변경 또는 `Accept-Language` 요청별
|
|
346
|
+
협상) 서버가 현지화된 문구를 보낸다. 클라이언트의 `locale` 은 서버 문구를 바꾸지 않는다.
|
|
347
|
+
|
|
348
|
+
클라이언트가 직접 만드는 문구:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
// 업로드 프리검증 — locale 미지정 = 현행 ko 문구
|
|
352
|
+
validateUpload({ fileName, size, head, maxSizeBytes, allowedExtensions, locale: "en" });
|
|
353
|
+
// → { ok: false, reason: "extension", message: "File type is not allowed: exe" }
|
|
354
|
+
validateUpload({ ...input, messages: { "rscc.upload.size": "10MB 이하만 올릴 수 있어요." } }); // 템플릿 오버라이드
|
|
355
|
+
|
|
356
|
+
// apiClient — 로컬 ApiError 문구 + opt-in Accept-Language
|
|
357
|
+
const api = createApiClient({ baseUrl, locale: "en", sendAcceptLanguage: true });
|
|
358
|
+
// 비봉투 HTTP 에러 → "API error: 503 Service Unavailable" · strictJson → "The response body is not valid JSON."
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- `validateUpload` 의 `messages`: `rscc.upload.size` / `rscc.upload.extension`(`{0}` = 확장자) / `rscc.upload.contentMismatch`
|
|
362
|
+
템플릿 — 준 키는 로케일과 무관하게 내장 문구보다 우선.
|
|
363
|
+
- `ApiClientConfig.locale` 은 클라이언트가 만드는 3종(본문 읽기 실패·`INVALID_JSON`·비봉투 HTTP 에러)에만 적용 —
|
|
364
|
+
실패 봉투의 `message` 는 서버 문구 그대로.
|
|
365
|
+
- **`sendAcceptLanguage`** (기본 false): true 이고 `locale` 이 있으면 `Accept-Language: <locale>` 부착(호출자 헤더 우선).
|
|
366
|
+
**브라우저는 사용자 언어 설정으로 `Accept-Language` 를 스스로 보낸다** — 이 옵션은 그 헤더가 없는 Node·SSR 이나
|
|
367
|
+
언어를 강제할 때 쓴다. 값은 BCP 47 태그로 둘 것(CORS safelisted — preflight 미유발).
|
|
368
|
+
|
|
184
369
|
### AES-256/GCM — Node 전용 서브패스
|
|
185
370
|
|
|
186
371
|
```ts
|
|
@@ -192,7 +377,10 @@ const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
|
|
|
192
377
|
|
|
193
378
|
## 알려진 제약
|
|
194
379
|
|
|
195
|
-
-
|
|
380
|
+
- **레거시 SSE 리더(`parseSseFrame`/`readSseStream`)는 프레임 구분자 LF(`\n\n`) 고정** — 개행을 CRLF 로 정규화하는
|
|
381
|
+
프록시 뒤에서는 프레임이 분리되지 않는다. 이 경우 `readSseChatEvents`(표준 파서 위 채팅 어댑터)를 쓸 것.
|
|
382
|
+
- 표준 SSE 리더(`readSseEvents`)는 스펙대로 **EOF 의 미완성 이벤트를 폐기**한다 — 서버는 이벤트를 빈 줄로 끝내야 한다
|
|
383
|
+
(레거시 호환이 필요하면 `dispatchIncompleteAtEof: true`).
|
|
196
384
|
- 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 기본적으로 조용히 null 이 된다 — 명시 실패가 필요하면 `strictJson: true`.
|
|
197
385
|
- 문자열 body 는 `application/json` 으로 간주된다 — JSON 이 아닌 문자열 본문은 Content-Type 을 직접 지정할 것.
|
|
198
386
|
- 교차 출처에서 traceId 에코를 읽으려면 서버가 `Access-Control-Expose-Headers: X-Trace-Id` 를 내려야 한다
|
|
@@ -205,6 +393,9 @@ const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
|
|
|
205
393
|
- `isValidRrn` 은 체크섬을 포함하지 않는다(2020-10 이후 발급분은 체크섬 불성립) — `rrnChecksumOkLegacy` 는 레거시 정합 검사 전용.
|
|
206
394
|
- `signWebhook`/`verifyWebhook` 은 WebCrypto 기반이라 **async** 다 (Java/Python 은 동기).
|
|
207
395
|
- 서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시는 **인스턴스(프로세스) 로컬** 상태다 — 분산 공유되지 않는다.
|
|
396
|
+
- datetime 의 `timeZone` 옵션은 `Intl.DateTimeFormat` 의 IANA 시간대 지원에 의존한다 — 지원하지 않는 런타임(시간대 데이터가
|
|
397
|
+
없는 경량 JS 엔진 등)에서는 `RangeError`. 옵션을 주지 않은 기본 경로는 `Intl` 을 쓰지 않는다.
|
|
398
|
+
- 내장 메시지 언어는 `ko`·`en` 뿐이다 — 그 외 언어는 `getMessage` 의 `overrides`(업로드는 `messages`)로 공급할 것.
|
|
208
399
|
- `@rscc/common-core/crypto` 는 `node:crypto` 를 쓰는 **Node 전용** 서브패스 — 브라우저 번들에 포함하지 말 것.
|
|
209
400
|
- 서브패스는 `exports` 맵으로만 노출된다 — TypeScript `moduleResolution` 이 레거시 `node`(`node10`)면
|
|
210
401
|
`@rscc/common-core/crypto` 의 타입을 찾지 못한다. `node16` / `nodenext` / `bundler` 를 사용할 것
|
|
@@ -212,5 +403,5 @@ const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
|
|
|
212
403
|
|
|
213
404
|
## 관련 패키지
|
|
214
405
|
|
|
215
|
-
- React 훅(`useDebounce`, `useSse`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
|
|
406
|
+
- React 훅(`useDebounce`, `useSse`, `useSseEvents`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
|
|
216
407
|
- 라이선스: MIT
|