@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/dist/index.js CHANGED
@@ -20,6 +20,93 @@ var ResultCode = {
20
20
  INTERNAL_SERVER_ERROR: "500"
21
21
  };
22
22
 
23
+ // src/csrf.ts
24
+ var DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
25
+ var DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
26
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
27
+ var SENTINEL_ORIGIN = "http://rscc-csrf.invalid";
28
+ var ABSOLUTE_URL = /^([a-zA-Z][a-zA-Z\d+.-]*):\/\/([^/?#\\]*)/;
29
+ var HAS_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
30
+ var PROTOCOL_RELATIVE = /^[\\/]{2}/;
31
+ var DEFAULT_PORTS = { http: "80", https: "443", ws: "80", wss: "443" };
32
+ function readCookie(name, cookieString) {
33
+ const source = cookieString ?? documentCookie();
34
+ if (!source) return null;
35
+ for (const part of source.split(";")) {
36
+ const pair = part.trim();
37
+ const eq = pair.indexOf("=");
38
+ if (eq < 0) continue;
39
+ if (pair.slice(0, eq) === name) return pair.slice(eq + 1);
40
+ }
41
+ return null;
42
+ }
43
+ function isUnsafeMethod(method) {
44
+ return !SAFE_METHODS.has((method || "GET").toUpperCase());
45
+ }
46
+ function csrfHeaderFor(url, method, options) {
47
+ if (!isUnsafeMethod(method)) return null;
48
+ const opts = options === true || options === void 0 ? {} : options;
49
+ const cookieString = opts.readCookie ? opts.readCookie() : documentCookie();
50
+ if (!cookieString) return null;
51
+ const token = readCookie(opts.cookieName ?? DEFAULT_CSRF_COOKIE_NAME, cookieString);
52
+ if (!token) return null;
53
+ if (!isAllowedTarget(url, opts.allowedOrigins)) return null;
54
+ return [opts.headerName ?? DEFAULT_CSRF_HEADER_NAME, token];
55
+ }
56
+ function documentCookie() {
57
+ if (typeof document === "undefined") return null;
58
+ try {
59
+ return typeof document.cookie === "string" ? document.cookie : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ function currentLocation() {
65
+ if (typeof location === "undefined") return null;
66
+ try {
67
+ const { href, origin } = location;
68
+ return typeof href === "string" && typeof origin === "string" ? { href, origin } : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function isAllowedTarget(url, allowedOrigins) {
74
+ const loc = currentLocation();
75
+ const base = loc ? loc.href : SENTINEL_ORIGIN;
76
+ const selfOrigin = loc ? loc.origin : SENTINEL_ORIGIN;
77
+ const target = originOf(url, base, selfOrigin);
78
+ if (target === null || target === "null") return false;
79
+ if (selfOrigin !== "null" && target === selfOrigin) return true;
80
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
81
+ return allowedOrigins.some((entry) => originOf(entry, base, selfOrigin) === target);
82
+ }
83
+ function originOf(url, base, baseOrigin) {
84
+ try {
85
+ if (typeof URL === "function") {
86
+ const origin = new URL(url, base).origin;
87
+ if (typeof origin === "string") return origin;
88
+ }
89
+ } catch {
90
+ }
91
+ return looseOriginOf(url, baseOrigin);
92
+ }
93
+ function looseOriginOf(url, baseOrigin) {
94
+ const trimmed = url.trim();
95
+ const m = ABSOLUTE_URL.exec(trimmed);
96
+ if (m) {
97
+ const scheme = m[1].toLowerCase();
98
+ const authority = m[2];
99
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1).toLowerCase();
100
+ const hp = /^(.*?)(?::(\d*))?$/.exec(hostPort);
101
+ const host = hp?.[1] ?? "";
102
+ const port = hp?.[2] ?? "";
103
+ if (!host) return null;
104
+ return port === "" || port === DEFAULT_PORTS[scheme] ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
105
+ }
106
+ if (PROTOCOL_RELATIVE.test(trimmed) || HAS_SCHEME.test(trimmed)) return null;
107
+ return baseOrigin;
108
+ }
109
+
23
110
  // src/idempotency.ts
24
111
  var FALLBACK_GROUPS = [8, 4, 4, 4, 12];
25
112
  function generateIdempotencyKey() {
@@ -163,6 +250,8 @@ function createApiClient(config) {
163
250
  async function requestWithMeta(path, init = {}) {
164
251
  const sentTraceId = generateTraceId();
165
252
  const method = (init.method ?? "GET").toUpperCase();
253
+ const url = joinUrl(config.baseUrl, path);
254
+ const credentials = init.credentials ?? config.credentials;
166
255
  const idempotencyHeader = config.idempotency?.header ?? "Idempotency-Key";
167
256
  const idempotencyKey = config.idempotency && (config.idempotency.methods ?? DEFAULT_IDEMPOTENCY_METHODS).includes(method) ? generateIdempotencyKey() : null;
168
257
  const attemptOnce = async () => {
@@ -171,16 +260,23 @@ function createApiClient(config) {
171
260
  if (idempotencyKey !== null && !headers.has(idempotencyHeader)) {
172
261
  headers.set(idempotencyHeader, idempotencyKey);
173
262
  }
174
- const isFormData = typeof FormData !== "undefined" && init.body instanceof FormData;
175
- if (!headers.has("Content-Type") && !isFormData) {
263
+ if (!headers.has("Content-Type") && typeof init.body === "string") {
176
264
  headers.set("Content-Type", "application/json");
177
265
  }
178
266
  if (config.getToken && !headers.has("Authorization")) {
179
267
  const token = config.getToken();
180
268
  if (token) headers.set("Authorization", `Bearer ${token}`);
181
269
  }
270
+ if (config.csrf) {
271
+ const csrfHeader = csrfHeaderFor(url, method, config.csrf);
272
+ if (csrfHeader && !headers.has(csrfHeader[0])) headers.set(csrfHeader[0], csrfHeader[1]);
273
+ }
182
274
  const fetchFn = config.fetchImpl ?? globalThis.fetch;
183
- const response = await fetchFn(joinUrl(config.baseUrl, path), { ...init, headers });
275
+ const response = await fetchFn(url, {
276
+ ...init,
277
+ ...credentials !== void 0 ? { credentials } : {},
278
+ headers
279
+ });
184
280
  const traceId = response.headers.get(traceIdHeader) ?? sentTraceId;
185
281
  let raw = null;
186
282
  try {
@@ -594,6 +690,12 @@ function createTtlCache(options) {
594
690
  }
595
691
 
596
692
  // src/circuitBreaker.ts
693
+ var CircuitOpenError = class extends Error {
694
+ constructor(message = "\uC11C\uD0B7 OPEN \u2014 \uC694\uCCAD \uCC28\uB2E8") {
695
+ super(message);
696
+ this.name = "CircuitOpenError";
697
+ }
698
+ };
597
699
  function createCircuitBreaker(options) {
598
700
  const { failureThreshold, openDurationMs, now = Date.now } = options;
599
701
  if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) {
@@ -606,6 +708,7 @@ function createCircuitBreaker(options) {
606
708
  let consecutiveFailures = 0;
607
709
  let openedAtMs = 0;
608
710
  let probeInFlight = false;
711
+ let probeStartedAtMs = 0;
609
712
  let inFlightGrants = 0;
610
713
  function allowRequest() {
611
714
  if (state === "CLOSED") {
@@ -613,18 +716,24 @@ function createCircuitBreaker(options) {
613
716
  return true;
614
717
  }
615
718
  if (state === "OPEN") {
616
- if (now() - openedAtMs >= openDurationMs) {
617
- if (inFlightGrants > 0) {
719
+ const nowMs2 = now();
720
+ const elapsedMs = nowMs2 - openedAtMs;
721
+ if (elapsedMs < openDurationMs) return false;
722
+ if (inFlightGrants > 0) {
723
+ if (elapsedMs < 2 * openDurationMs) {
618
724
  return false;
619
725
  }
620
- state = "HALF_OPEN";
621
- probeInFlight = true;
622
- return true;
726
+ inFlightGrants = 0;
623
727
  }
624
- return false;
728
+ state = "HALF_OPEN";
729
+ probeInFlight = true;
730
+ probeStartedAtMs = nowMs2;
731
+ return true;
625
732
  }
626
- if (probeInFlight) return false;
733
+ const nowMs = now();
734
+ if (probeInFlight && nowMs - probeStartedAtMs < openDurationMs) return false;
627
735
  probeInFlight = true;
736
+ probeStartedAtMs = nowMs;
628
737
  return true;
629
738
  }
630
739
  function onSuccess() {
@@ -659,13 +768,40 @@ function createCircuitBreaker(options) {
659
768
  }
660
769
  inFlightGrants = Math.max(0, inFlightGrants - 1);
661
770
  }
771
+ function onIgnore() {
772
+ if (state === "HALF_OPEN") {
773
+ probeInFlight = false;
774
+ return;
775
+ }
776
+ inFlightGrants = Math.max(0, inFlightGrants - 1);
777
+ }
778
+ async function execute(fn) {
779
+ if (!allowRequest()) {
780
+ throw new CircuitOpenError();
781
+ }
782
+ let result;
783
+ try {
784
+ result = await fn();
785
+ } catch (error) {
786
+ if (isAbortError(error)) onIgnore();
787
+ else onFailure();
788
+ throw error;
789
+ }
790
+ onSuccess();
791
+ return result;
792
+ }
662
793
  return {
663
794
  allowRequest,
664
795
  onSuccess,
665
796
  onFailure,
797
+ onIgnore,
798
+ execute,
666
799
  state: () => state
667
800
  };
668
801
  }
802
+ function isAbortError(error) {
803
+ return typeof error === "object" && error !== null && error.name === "AbortError";
804
+ }
669
805
 
670
806
  // src/tokenBucket.ts
671
807
  function createTokenBucket(options) {
@@ -1646,6 +1782,9 @@ function matchesHangul(query, target) {
1646
1782
  export {
1647
1783
  ApiError,
1648
1784
  BulkheadFullError,
1785
+ CircuitOpenError,
1786
+ DEFAULT_CSRF_COOKIE_NAME,
1787
+ DEFAULT_CSRF_HEADER_NAME,
1649
1788
  ResultCode,
1650
1789
  WEBHOOK_SIGNATURE_HEADER,
1651
1790
  abbreviateAmount,
@@ -1665,6 +1804,7 @@ export {
1665
1804
  createFeatureFlags,
1666
1805
  createTokenBucket,
1667
1806
  createTtlCache,
1807
+ csrfHeaderFor,
1668
1808
  decodeJwtPayload,
1669
1809
  decomposeHangul,
1670
1810
  formatPhoneNumber,
@@ -1675,6 +1815,7 @@ export {
1675
1815
  isForeignerRrn,
1676
1816
  isRetryableStatus,
1677
1817
  isTokenExpired,
1818
+ isUnsafeMethod,
1678
1819
  isValidBusinessNumber,
1679
1820
  isValidCorporateNumber,
1680
1821
  isValidRrn,
@@ -1694,6 +1835,7 @@ export {
1694
1835
  parseSseFrame,
1695
1836
  parseWireDateTime,
1696
1837
  pickJosa,
1838
+ readCookie,
1697
1839
  readSseStream,
1698
1840
  retry,
1699
1841
  rrnBirthDate,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rscc/common-core",
3
- "version": "0.2.0",
4
- "description": "RSCC 공통 코어 — CommonResponse 타입, apiClient 팩토리(traceId 발신/에코), SSE 프레임 파서, JWT 디코더, 마스킹 유틸. 프레임워크 무관, 런타임 의존성 0.",
3
+ "version": "0.4.0",
4
+ "description": "RSCC 공통 코어 — CommonResponse 타입·apiClient(traceId·재시도·멱등성 키), SSE 파서, 회복탄력성(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안(마스킹·로그 리댁션·웹훅 서명·JWT 디코드·AES-GCM 서브패스), 와이어 계약 헬퍼, 한국 도메인 유틸. 프레임워크 무관, 런타임 의존성 0.",
5
5
  "keywords": [
6
6
  "rscc",
7
7
  "common-response",
@@ -24,7 +24,7 @@
24
24
  "url": "https://github.com/Jeonghyeon-Ryu/r-common/issues"
25
25
  },
26
26
  "engines": {
27
- "node": ">=18"
27
+ "node": ">=20"
28
28
  },
29
29
  "publishConfig": {
30
30
  "access": "public"