@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/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
|
-
|
|
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(
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
621
|
-
probeInFlight = true;
|
|
622
|
-
return true;
|
|
726
|
+
inFlightGrants = 0;
|
|
623
727
|
}
|
|
624
|
-
|
|
728
|
+
state = "HALF_OPEN";
|
|
729
|
+
probeInFlight = true;
|
|
730
|
+
probeStartedAtMs = nowMs2;
|
|
731
|
+
return true;
|
|
625
732
|
}
|
|
626
|
-
|
|
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.
|
|
4
|
-
"description": "RSCC 공통 코어 — CommonResponse
|
|
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": ">=
|
|
27
|
+
"node": ">=20"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|