app-fetch 1.0.1
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/LICENSE +21 -0
- package/README.md +412 -0
- package/dist/@types/app-fetch.d.mts +161 -0
- package/dist/app-fetch.cjs +1 -0
- package/dist/app-fetch.min.js +1 -0
- package/dist/app-fetch.mjs +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jaeryeol2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# app-fetch 🚀
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/app-fetch)
|
|
4
|
+
[](https://www.npmjs.com/package/app-fetch)
|
|
5
|
+
[](https://github.com/jaeryeol2/app-fetch)
|
|
6
|
+
[](https://github.com/jaeryeol2/app-fetch/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
> **Native Web Fetch API Wrapper Library**
|
|
9
|
+
> `app-fetch`는 Web 표준 `fetch` API를 기반으로 제작된 경량(Zero-Dependency) 타입안전 HTTP 클라이언트 래퍼 라이브러리입니다.
|
|
10
|
+
|
|
11
|
+
- **Author**: jaeryeol2
|
|
12
|
+
- **GitHub**: [github.com/jaeryeol2/app-fetch](https://github.com/jaeryeol2/app-fetch)
|
|
13
|
+
- **npm**: [npmjs.com/package/app-fetch](https://www.npmjs.com/package/app-fetch)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 📌 주요 특징 (Key Features)
|
|
18
|
+
|
|
19
|
+
- ⚡ **Zero Dependencies & Native Fetch 기반**: 별도의 외부 종속성 없이 브라우저 및 Node.js 네이티브 `fetch` API를 활용합니다.
|
|
20
|
+
- 📦 **DUAL ESM & CommonJS 지원**: `tsdown`으로 빌드되어 `.mjs` 및 `.cjs` 번들을 모두 제공합니다.
|
|
21
|
+
- 🛠 **인스턴스 생성 (`appFetch.create`)**: `baseURL`, 기본 헤더, 인터셉터, 타임아웃 설정을 캡슐화한 커스텀 클라이언트를 생성할 수 있습니다.
|
|
22
|
+
- 🔍 **중첩 쿼리 파라미터 직렬화 (`query`)**: 배열(`tags[0]=ts`), 중첩 객체, Date, Map, Set 등의 파라미터를 자동으로 인코딩 및 URL 쿼리 스트링으로 변환합니다. (순환 참조 감지 포함)
|
|
23
|
+
- 📝 **스마트 요청 바디 처리 (`body`)**: Plain Object 입력 시 `Content-Type: application/json` 헤더 추가 및 자동 `JSON.stringify`를 수행하며, `FormData`, `Blob`, `URLSearchParams`는 유지합니다.
|
|
24
|
+
- 🪝 **강력한 인터셉터 (`beforeRequest`, `afterResponse`, `onError`)**: 단일 함수 또는 배열 형태의 인터셉터를 체이닝하여 공통 헤더 주입, 토큰 갱신, 에러 로깅 등을 처리합니다.
|
|
25
|
+
- ⏱ **타임아웃 & 자동 재시도 (`timeout`, `retry`, `delay`)**: `AbortController` 기반 타임아웃(기본 3,000ms) 및 일시적 오류(408, 429, 5xx 서버 오류 및 네트워크 단절) 발생 시 안전한 자동 재시도 기능을 제공합니다.
|
|
26
|
+
- 📄 **스마트 응답 파서 (`getData` & `.getData()`)**: `await appFetch(...).getData()` 직접 체이닝, `response.getData()`, `getData(response)` 헬퍼 함수 모두 지원하며, `Content-Type` 및 응답 상태에 따라 JSON, Blob(이미지, PDF, 바이너리), FormData, Plain Text 등을 자동 판별하여 파싱합니다.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 📥 설치 및 빌드 (Installation & Build)
|
|
31
|
+
|
|
32
|
+
### 빌드 명령
|
|
33
|
+
```bash
|
|
34
|
+
npm run build
|
|
35
|
+
```
|
|
36
|
+
### 📦 빌드 산출물 구조 (`dist/`) 및 파일별 상세 설명
|
|
37
|
+
|
|
38
|
+
`npm run build` 실행 시 `tsdown` 및 후처리 스크립트에 의해 `dist/` 디렉토리에 런타임 환경별 번들 파일이 생성됩니다.
|
|
39
|
+
|
|
40
|
+
| 산출물 파일 | 빌드 포맷 | 주요 대상 환경 | 상세 설명 |
|
|
41
|
+
| :--- | :--- | :--- | :--- |
|
|
42
|
+
| **`dist/app-fetch.mjs`** | **ESM** (ES Module) | React, Vue, Svelte, Next.js App Router, Vite, Nuxt 3 | ESNext 모듈 표준으로 `import { appFetch } from 'app-fetch'` 구문을 사용하는 최신 모듈 번들러 및 SSR 환경 전용 번들입니다. 트리쉐이킹(Tree-shaking)을 지원합니다. |
|
|
43
|
+
| **`dist/app-fetch.cjs`** | **CommonJS** (CJS) | Node.js 백엔드 서버 (NestJS, Express, Fastify 등) | Node.js의 `const { appFetch } = require('app-fetch')` 구문 환경에서 동작하는 레거시 및 백엔드 CommonJS 모듈 번들입니다. |
|
|
44
|
+
| **`dist/app-fetch.min.js`** | **IIFE** (Minified Global) | JSP, 레거시 HTML, 스크립트 태그 (`<script>`) 로드 환경 | 모듈 번들러가 없는 단일 HTML/JSP 환경에서 `<script src="app-fetch.min.js"></script>`로 직접 로드할 수 있는 경량화 번들입니다. 브라우저 전역 객체 `window.appFetch`에 자동 노출됩니다. |
|
|
45
|
+
| **`dist/@types/app-fetch.d.mts`** | **DTS** (TypeScript Declaration) | TypeScript 개발 환경 | IDE(VS Code 등)에서 코드 자동 완성, 타입 검사 및 `AppFetchOptions`, `FetchInterceptors` 등의 타입 사양을 제공하는 선언 파일입니다. |
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 💡 사용법 (Usage Examples)
|
|
50
|
+
|
|
51
|
+
### 1. 기본 요청 (Basic Request)
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { appFetch, getData } from 'app-fetch';
|
|
55
|
+
|
|
56
|
+
// 방법 1) Promise 메서드 직접 체이닝 (가장 추천하는 간결한 방법 🌟)
|
|
57
|
+
const users = await appFetch('https://api.example.com/users', {
|
|
58
|
+
query: { page: 1, limit: 10 },
|
|
59
|
+
}).getData();
|
|
60
|
+
|
|
61
|
+
// 방법 2) Response 인스턴스를 받아 .getData() 메서드 직접 호출
|
|
62
|
+
const response = await appFetch('https://api.example.com/users');
|
|
63
|
+
const usersAlt1 = await response.getData();
|
|
64
|
+
|
|
65
|
+
// 방법 3) 기존 글로벌 getData(response) 헬퍼 함수 사용
|
|
66
|
+
const usersAlt2 = await getData(response);
|
|
67
|
+
|
|
68
|
+
// POST 요청 (객체 바디 전달 시 Content-Type 자동 설정 및 .getData() 체이닝)
|
|
69
|
+
const createdUser = await appFetch('https://api.example.com/users', {
|
|
70
|
+
method: 'post',
|
|
71
|
+
body: { name: 'Hong Gil-dong', email: 'hong@example.com' },
|
|
72
|
+
}).getData();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### 🌐 JSP / HTML 환경 (Script Tag 사용)
|
|
76
|
+
|
|
77
|
+
```html
|
|
78
|
+
<!-- dist/app-fetch.min.js 파일을 script 태그로 로드 -->
|
|
79
|
+
<script src="/js/dist/app-fetch.min.js"></script>
|
|
80
|
+
<script>
|
|
81
|
+
// window.appFetch 전역 객체 사용
|
|
82
|
+
const { appFetch, getData } = window.appFetch;
|
|
83
|
+
|
|
84
|
+
async function fetchUsers() {
|
|
85
|
+
// appFetch 직접 체이닝 파싱 (.getData())
|
|
86
|
+
const users = await appFetch('/api/users', { query: { page: 1 } }).getData();
|
|
87
|
+
console.log(users);
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 2. 커스텀 인스턴스 생성 (`appFetch.create`)
|
|
93
|
+
|
|
94
|
+
`appFetch.create(defaults)`를 사용하면 공통 `baseURL`, 기본 헤더, 타임아웃, 인터셉터 등이 미리 주입된 독립적인 API 클라이언트 인스턴스를 생성할 수 있습니다.
|
|
95
|
+
|
|
96
|
+
#### ⚙️ 인스턴스 기본 사용법
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { appFetch, getData } from 'app-fetch';
|
|
100
|
+
|
|
101
|
+
// 1. 공통 옵션이 설정된 커스텀 인스턴스 생성
|
|
102
|
+
const apiClient = appFetch.create({
|
|
103
|
+
baseURL: 'https://api.example.com',
|
|
104
|
+
timeout: 5000,
|
|
105
|
+
retry: 2,
|
|
106
|
+
delay: 100,
|
|
107
|
+
headers: {
|
|
108
|
+
'X-Client-Version': '1.0.0',
|
|
109
|
+
},
|
|
110
|
+
beforeRequest: (options) => {
|
|
111
|
+
const headers = options.headers as Headers;
|
|
112
|
+
headers.set('Authorization', 'Bearer my-access-token');
|
|
113
|
+
},
|
|
114
|
+
onError: (error) => {
|
|
115
|
+
console.error('[API Error Logged]:', error);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 2. 생성된 인스턴스로 API 호출 (기본 설정 자동 적용)
|
|
120
|
+
const response = await apiClient('/v1/products', {
|
|
121
|
+
query: { category: 'electronics' },
|
|
122
|
+
});
|
|
123
|
+
const products = await getData(response);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### 🔄 인스턴스 옵션 병합 및 인터셉터 체이닝 원리
|
|
127
|
+
|
|
128
|
+
- **Headers 병합 (`mergeHeaders`)**: `defaults.headers`와 호출 시 전달된 `options.headers`는 네이티브 `Headers` 객체 속성을 유지하며 안전하게 `set()` 처리됩니다.
|
|
129
|
+
- **Interceptors 체이닝 (`composeInterceptors`)**: `beforeRequest`, `afterResponse`, `onError` 인터셉터는 기본 설정에 정의된 인터셉터 뒤에 개별 호출 시 넘긴 인터셉터가 순차적으로 결합되어 순서대로 실행됩니다.
|
|
130
|
+
- **옵션 덮어쓰기**: `timeout`, `retry`, `delay` 등 일반 값은 호출 시 전달된 개별 옵션이 기본값을 덮어씁니다.
|
|
131
|
+
|
|
132
|
+
#### 🏢 멀티 테넌트 / 마이크로서비스별 인스턴스 분리
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// 회원 서비스용 클라이언트
|
|
136
|
+
const userApi = appFetch.create({
|
|
137
|
+
baseURL: 'https://user-service.internal',
|
|
138
|
+
timeout: 3000,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 결제 서비스용 클라이언트
|
|
142
|
+
const paymentApi = appFetch.create({
|
|
143
|
+
baseURL: 'https://payment-service.internal',
|
|
144
|
+
timeout: 10000,
|
|
145
|
+
retry: 3,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const userInfo = await userApi('/profile');
|
|
149
|
+
const paymentResult = await paymentApi('/charge', { method: 'post', body: { amount: 50000 } });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 3. 중첩 쿼리 파라미터 직렬화
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
await appFetch('https://api.example.com/search', {
|
|
156
|
+
query: {
|
|
157
|
+
filter: {
|
|
158
|
+
status: 'active',
|
|
159
|
+
tags: ['typescript', 'javascript'],
|
|
160
|
+
},
|
|
161
|
+
page: 2,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// 변환된 URL:
|
|
166
|
+
// https://api.example.com/search?filter.status=active&filter.tags%5B0%5D=typescript&filter.tags%5B1%5D=javascript&page=2
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### 4. 타임아웃 및 재시도 전략 (Timeout & Strategy Pattern Retry)
|
|
170
|
+
|
|
171
|
+
`app-fetch`는 단순 카운터 방식 외에도 **Strategy Pattern(재시도 전략 패턴)**을 탑재하여 지수 백오프(Exponential Backoff), 커스텀 조건부 재시도 정책을 유연하게 주입할 수 있습니다.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { appFetch, exponentialBackoffRetry } from 'app-fetch';
|
|
175
|
+
|
|
176
|
+
// 1. 기본 재시도 옵션 사용 (하위 호환성 보장)
|
|
177
|
+
const response1 = await appFetch('https://api.example.com/flaky', {
|
|
178
|
+
timeout: 2000, // 시도당(per-attempt) 2초 타임아웃
|
|
179
|
+
retry: 3, // 서버 오류(5xx, 408, 429) 또는 네트워크 에러 시 최대 3회 재시도
|
|
180
|
+
delay: 500, // 재시도 대기 간격 500ms
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// 2. Strategy Pattern - 내장 지수 백오프(Exponential Backoff) 전략 사용 🌟
|
|
184
|
+
const response2 = await appFetch('https://api.example.com/unstable', {
|
|
185
|
+
retryStrategy: exponentialBackoffRetry({
|
|
186
|
+
maxRetries: 3,
|
|
187
|
+
initialDelay: 100, // 100ms, 200ms, 400ms 지수 백오프
|
|
188
|
+
factor: 2,
|
|
189
|
+
statusCodes: [500, 502, 503, 504], // 해당 서버 오류 코드에서만 선택적 재시도
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// 3. Strategy Pattern - 사용자 정의 커스텀 전략 객체 주입
|
|
194
|
+
const response3 = await appFetch('https://api.example.com/custom', {
|
|
195
|
+
retryStrategy: {
|
|
196
|
+
shouldRetry: (context) => {
|
|
197
|
+
// 401 Unauthorized 에러 발생 시 재시도 안함
|
|
198
|
+
if (context.response?.status === 401) return false;
|
|
199
|
+
return context.attempt <= 3;
|
|
200
|
+
},
|
|
201
|
+
getDelay: (context) => context.attempt * 200,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
#### 💡 재시도 및 타임아웃 동작 방식 (Retry & Timeout Details)
|
|
207
|
+
|
|
208
|
+
- **`timeout`은 각 시도당(Per-Attempt) 적용됩니다.** 전체 요청 예산(Total Budget)이 아니므로, `timeout: 3000, retry: 3` 설정 시 각 시도마다 3초의 타임아웃이 개별 적용되어 최악의 경우 (4회 시도 * 3초) + 재시도 지연 시간만큼 소요될 수 있습니다.
|
|
209
|
+
- **`beforeRequest`는 매 재시도 시에도 실행됩니다.** 재시도 시에도 인터셉터가 다시 실행되므로, 토큰 갱신이나 헤더 주입이 재시도 요청에서도 온전히 유지됩니다.
|
|
210
|
+
- **기본 재시도 필터링:** 기본 `retry: N` 옵션은 `400`, `401`, `404` 등 일반 4xx 클라이언트 에러를 재시도하지 않으며, 일시적 복구 가능성이 있는 **`408`, `429`, `5xx` 서버 에러 및 네트워크 단절 에러**만 재시도합니다.
|
|
211
|
+
- **사용자 요청 취소(`signal.abort()`) 시 즉시 중단:** 사용자가 전달한 `AbortSignal`이 취소(`aborted: true`)되면 남아있는 재시도 카운트와 무관하게 모든 재시도가 즉시 중단되고 `AbortError`를 발생시켜 불필요한 중복 트래픽을 방지합니다.
|
|
212
|
+
- **요청 바디가 `ReadableStream`인 경우 재시도가 자동으로 차단됩니다.** 스트림은 한 번 소비되면 다시 읽을 수 없어(1회성 소비), 동일한 스트림으로 재시도를 시도하면 두 번째 요청이 반드시 실패합니다. `app-fetch`는 이런 상황에서 `retry`/`retryStrategy` 설정과 무관하게 재시도를 건너뛰고 최초 응답/에러를 그대로 반환하며, 콘솔에 `console.warn`으로 원인을 안내합니다. 스트리밍 업로드에서 재시도가 필요하다면 `Blob`, `ArrayBuffer`, `string`, `FormData`처럼 재사용 가능한 바디 타입을 사용해 주세요.
|
|
213
|
+
- **안전 하드캡(Hard Cap):** 재시도는 최대 10회로 제한되며, 10회 도달 시 무한 루프를 방지하기 위해 경고(`console.warn`)와 함께 재시도를 종료합니다.
|
|
214
|
+
|
|
215
|
+
### 5. 응답 파싱 및 지원 포맷 (`getData`, `HttpError`, `returnError`)
|
|
216
|
+
|
|
217
|
+
`getData`는 `Content-Type` 헤더를 분석하여 적절한 데이터 타입으로 자동 파싱하며, 스트림 잠김(Locked Body Stream) 방지를 위해 `response.clone()` 기반으로 안전하게 처리됩니다.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { appFetch, getData, HttpError, returnError } from 'app-fetch';
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const response = await appFetch('https://api.example.com/data');
|
|
224
|
+
const data = await getData(response);
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
throw new HttpError('Request failed', response.status);
|
|
228
|
+
}
|
|
229
|
+
console.log('Parsed Data:', data);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const errorResponse = returnError(error);
|
|
232
|
+
// { status: 500 | status, message: '...', data: null }
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
#### 📦 `getData` 자동 지원 `Content-Type` 카테고리
|
|
237
|
+
|
|
238
|
+
| 카테고리 | 매칭 `Content-Type` 패턴 | 반환 타입 | 상세 내용 |
|
|
239
|
+
| :--- | :--- | :--- | :--- |
|
|
240
|
+
| **JSON** | `application/json`, `application/problem+json`, `application/ld+json`, `*.json` | `T` (JSON Object/Array) | `await response.json()` 자동 파싱 |
|
|
241
|
+
| **바이너리 (Blob)** | `image/*`, `audio/*`, `video/*`, `font/*`, `application/octet-stream`, `pdf`, `zip`, `tar`, `gzip`, `7z`, `rar`, `epub`, `excel`, `word`, `officedocument`, `vnd.ms-` | `Blob` | 파일 다운로드, 이미지/미디어 스트림 |
|
|
242
|
+
| **FormData** | `multipart/*`, `application/x-www-form-urlencoded` | `FormData` | `await response.formData()` 자동 파싱 |
|
|
243
|
+
| **텍스트 / 스크립트** | `text/*`, `application/xml`, `text/xml`, `application/javascript`, `text/javascript`, `application/typescript`, `application/yaml`, `application/graphql` | `string` | `await response.text()` 자동 파싱 |
|
|
244
|
+
| **Empty Body** | 상태코드 `204 No Content`, `205 Reset Content`, 헤더 `Content-Length: 0` | `null` | 바디가 없는 응답에 대해 `null` 반환 |
|
|
245
|
+
| **Fallback** | Content-Type 미지정 또는 알 수 없는 형식 | `string \| Blob \| null` | `text()` 시도 후 실패 시 `blob()` 순차적 Fallback |
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
### 6. 프로젝트 전역 커스텀 래퍼 구축 및 타입 커스텀 패턴 (`sampleFetch`)
|
|
250
|
+
|
|
251
|
+
실무 프로젝트마다 백엔드 API의 응답 구조(Response Envelope - 예: `{ status, message, data }` 또는 `{ code, result, isSuccess }`)가 다를 수 있습니다.
|
|
252
|
+
`app-fetch`는 특정 프로젝트 스키마에 종속되지 않도록 설계되어 있으며, 프로젝트 환경에 맞춰 아래와 같이 전역 응답 타입(`ResponseApi<T>`) 및 커스텀 래퍼 클라이언트를 손쉽게 구성할 수 있습니다. (`examples/sample.ts` 참고)
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import { appFetch, getData, HttpError, returnError, mergeFetchOptions } from 'app-fetch';
|
|
256
|
+
import type { FetchInterceptors, AppFetchOptions } from 'app-fetch';
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 실무 프로젝트 전역에서 사용하는 백엔드 공통 API 응답 규격 타입 정의 샘플입니다.
|
|
260
|
+
* 프로젝트 사양(예: { code: string, result: T, isSuccess: boolean })에 맞춰 자유롭게 커스텀할 수 있습니다.
|
|
261
|
+
*/
|
|
262
|
+
export interface ResponseApi<T> {
|
|
263
|
+
status: number;
|
|
264
|
+
message: string;
|
|
265
|
+
data: T | null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 프로젝트 전역에서 공유되는 공통 인터셉터 레지스트리 객체입니다.
|
|
270
|
+
*/
|
|
271
|
+
const globalInterceptors: FetchInterceptors = {
|
|
272
|
+
beforeRequest: [],
|
|
273
|
+
afterResponse: [],
|
|
274
|
+
onError: [],
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* appFetch.create()를 이용하여 공통 baseURL, timeout, 헤더가 캡슐화된 싱글톤 API 인스턴스 생성
|
|
279
|
+
*/
|
|
280
|
+
const baseFetch = appFetch.create({
|
|
281
|
+
baseURL: 'https://api.example.com',
|
|
282
|
+
timeout: 5000,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Raw Web Native Response 객체를 그대로 반환하는 메서드
|
|
287
|
+
*/
|
|
288
|
+
const native = (path: string, options?: AppFetchOptions): Promise<Response> => {
|
|
289
|
+
const mergedOptions = mergeFetchOptions(globalInterceptors, options);
|
|
290
|
+
return baseFetch(path, mergedOptions);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 백엔드 공통 응답 규격(ResponseApi<R>) 형태로 응답을 감싸서 반환하는 Wrap Fetch 메서드
|
|
295
|
+
*/
|
|
296
|
+
const wrap = async <R = unknown>(
|
|
297
|
+
path: string,
|
|
298
|
+
options?: AppFetchOptions,
|
|
299
|
+
): Promise<ResponseApi<R>> => {
|
|
300
|
+
try {
|
|
301
|
+
// 1. 전역 인터셉터와 요청별 개별 옵션 병합
|
|
302
|
+
const mergedOptions = mergeFetchOptions(globalInterceptors, options);
|
|
303
|
+
|
|
304
|
+
// 2. HTTP 통신 수행 및 헤더 기반 데이터 파싱 (JSON, Blob, FormData, Text 등)
|
|
305
|
+
const response = await baseFetch(path, mergedOptions);
|
|
306
|
+
const responseData = await getData(response);
|
|
307
|
+
|
|
308
|
+
// 3. HTTP 응답 비정상(4xx, 5xx) 상태 감지 시 HttpError 예외 발생
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
let backendMessage = 'do not get response data.';
|
|
311
|
+
if (responseData && typeof responseData === 'object' && 'message' in responseData) {
|
|
312
|
+
backendMessage = String((responseData as Record<string, unknown>).message);
|
|
313
|
+
} else if (typeof responseData === 'string' && responseData) {
|
|
314
|
+
backendMessage = responseData;
|
|
315
|
+
}
|
|
316
|
+
throw new HttpError(backendMessage, response.status);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 4-A. 파일 다운로드 / 바이너리 응답 (Blob) 인 경우
|
|
320
|
+
if (responseData instanceof Blob) {
|
|
321
|
+
return { status: response.status, message: 'success', data: responseData as unknown as R };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 4-B. 백엔드에서 이미 { data: ... } 형태로 감싸서 응답한 경우
|
|
325
|
+
if (responseData && typeof responseData === 'object' && 'data' in responseData) {
|
|
326
|
+
return responseData as ResponseApi<R>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 4-C. 일반 JSON 객체 또는 단일 데이터인 경우 공통 규격으로 포맷팅
|
|
330
|
+
return { status: response.status, message: 'success', data: (responseData ?? null) as R };
|
|
331
|
+
} catch (error) {
|
|
332
|
+
// 5. 예외 발생 시 표준 오류 구조체로 안전하게 변환하여 반환
|
|
333
|
+
return returnError<R>(error);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 프로젝트 메인 Fetch 클라이언트 엔트리포인트 객체
|
|
339
|
+
*/
|
|
340
|
+
export const sampleFetch = Object.assign(wrap, { native });
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
### 7. 내부 안전 가드 및 SSR 안정성 (Safety & Chaos Guards) 🛡️
|
|
346
|
+
|
|
347
|
+
`app-fetch`는 예측 불가능한 네트워크 환경 및 복잡한 SSR/CSR 전환 환경에서도 시스템이 다운되거나 무한 루프에 빠지지 않도록 내장 안전 가드를 탑재하고 있습니다.
|
|
348
|
+
|
|
349
|
+
1. **무한 재시도 핑퐁 차단 (Safety Hard-Cap 10회)**:
|
|
350
|
+
- 잘못 구성된 커스텀 재시도 전략이나 플래키 네트워크로 인한 무한 루프 폭주를 원천 차단하기 위해 **최대 10회 초과 시 재시도를 강제 종료**합니다.
|
|
351
|
+
2. **`beforeRequest` 비동기 타임아웃 즉시 차단**:
|
|
352
|
+
- 비동기 인터셉터(토큰 갱신 등) 실행 도중 타임아웃(`options.timeout`)이 초과되면 `Promise.race`를 통해 `AbortSignal` 이벤트를 감지하여 즉시 요청을 중단하고 `AbortError`를 발생시킵니다.
|
|
353
|
+
3. **`response.clone()` 스트림 잠김 방지**:
|
|
354
|
+
- `afterResponse` 인터셉터 로깅 및 `getData` 본문 파싱 시 원본 Response 스트림이 잠겨(Locked Body Stream) 재사용이 불가능해지는 문제를 방지하기 위해 내부적으로 `response.clone()`을 체계적으로 활용합니다.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## 📖 API Reference
|
|
359
|
+
|
|
360
|
+
### `appFetch(path, options)` & `appFetch.create(defaults)`
|
|
361
|
+
|
|
362
|
+
| 함수 / 메서드 | 파라미터 | 반환 타입 | 설명 |
|
|
363
|
+
| :--- | :--- | :--- | :--- |
|
|
364
|
+
| **`appFetch(path, options)`** | `path: string`, `options?: AppFetchOptions` | `AppFetchPromise` | HTTP 요청을 수행하며, `await appFetch(...).getData()` 체이닝 및 `res.getData()`를 지원하는 확장 Promise를 반환합니다. |
|
|
365
|
+
| **`appFetch.create(defaults)`** | `defaults: Omit<AppFetchOptions, 'method' \| 'query' \| 'body'>` | `(path: string, options?: AppFetchOptions) => AppFetchPromise` | 공통 `baseURL`, 기본 헤더, 타임아웃, 인터셉터가 캡슐화된 커스텀 클라이언트 인스턴스 함수를 생성합니다. |
|
|
366
|
+
|
|
367
|
+
### `AppFetchOptions` (Discriminated Union)
|
|
368
|
+
|
|
369
|
+
`AppFetchOptions`는 TypeScript의 **Discriminated Union**으로 구성되어 있어, `GET/DELETE` 및 `POST/PUT/PATCH` 메서드 모두에서 `query` 파라미터를 자유롭게 전달할 수 있으며, `body` 옵션은 `POST/PUT/PATCH` 메서드에서 안전하게 허용됩니다.
|
|
370
|
+
|
|
371
|
+
| 옵션명 | 타입 | 기본값 | 설명 |
|
|
372
|
+
| :--- | :--- | :---: | :--- |
|
|
373
|
+
| `baseURL` | `string` | `undefined` | 모든 상대 경로에 결합될 기본 URL |
|
|
374
|
+
| `method` | `'get' \| 'delete' \| 'post' \| 'put' \| 'patch'` | `'get'` | HTTP 메서드 |
|
|
375
|
+
| `query` | `Record<string, unknown> \| object` | `undefined` | 모든 HTTP 요청 시 URL 쿼리 스트링으로 직렬화할 파라미터 객체 (중첩 객체/배열/Map/Set/Date 지원) |
|
|
376
|
+
| `body` | `Record<string, unknown> \| BodyInit` | `undefined` | POST / PUT / PATCH 요청 시 전송할 바디 (Object는 자동 JSON 직렬화) |
|
|
377
|
+
| `headers` | `HeadersInit` | `undefined` | 요청 헤더 (`mergeHeaders`를 통해 네이티브 Headers 속성 유지) |
|
|
378
|
+
| `timeout` | `number` | `3000` | 각 시도당(per-attempt) 요청 타임아웃 (ms) |
|
|
379
|
+
| `retry` | `number` | `0` | 일시적 오류(408, 429, 5xx 및 네트워크 에러) 시 단순 재시도 횟수 |
|
|
380
|
+
| `delay` | `number` | `0` | 단순 재시도 대기 간격 (ms) |
|
|
381
|
+
| `retryStrategy` | `RetryStrategy` | `undefined` | Strategy Pattern 기반 커스텀 재시도 전략 함수/객체 |
|
|
382
|
+
| `signal` | `AbortSignal` | `undefined` | 외부 AbortSignal (내부 타임아웃 Signal과 `AbortSignal.any`로 자동 합성, 취소 시 재시도 즉시 중단) |
|
|
383
|
+
| `beforeRequest` | `BeforeRequestInterceptorType \| BeforeRequestInterceptorType[]` | `undefined` | 요청 전송 전 실행되는 인터셉터 (매 재시도 시에도 재실행) |
|
|
384
|
+
| `afterResponse` | `AfterResponseInterceptorType \| AfterResponseInterceptorType[]` | `undefined` | 응답 수신 직후 실행되는 인터셉터 (`response.clone()` 제공) |
|
|
385
|
+
| `onError` | `OnErrorType \| OnErrorType[]` | `undefined` | 통신 실패 및 타임아웃 발생 시 실행되는 에러 인터셉터 |
|
|
386
|
+
|
|
387
|
+
### 헬퍼 함수 (Helper Functions)
|
|
388
|
+
|
|
389
|
+
- **`exponentialBackoffRetry(config?): RetryStrategyFunction`**
|
|
390
|
+
지수 백오프(Exponential Backoff) 기반의 재시도 전략 함수를 생성하는 팩토리 헬퍼입니다.
|
|
391
|
+
|
|
392
|
+
| 설정 속성 (`config`) | 타입 | 기본값 | 설명 |
|
|
393
|
+
| :--- | :--- | :---: | :--- |
|
|
394
|
+
| `maxRetries` | `number` | `3` | 최대 재시도 횟수 |
|
|
395
|
+
| `initialDelay` | `number` | `100` | 초기 대기 시간 (ms, $100 \times \text{factor}^{\text{attempt}-1}$) |
|
|
396
|
+
| `factor` | `number` | `2` | 지수 증가 배수 |
|
|
397
|
+
| `statusCodes` | `number[]` | `[408, 429, 500, 502, 503, 504]` | 선택적 재시도 대상 HTTP 상태 코드 목록 |
|
|
398
|
+
|
|
399
|
+
- **`getData<T>(response: Response): Promise<T | Blob | FormData | string | null>`**
|
|
400
|
+
Response 헤더의 `Content-Type`을 기반으로 데이터를 적절한 타입(JSON, Blob, FormData, Text 등)으로 자동 파싱하는 헬퍼입니다.
|
|
401
|
+
- **`composeInterceptors<T>(base, custom): T[] | undefined`**
|
|
402
|
+
기본 인스턴스의 인터셉터와 개별 요청 시 전달된 인터셉터를 순서대로 안전하게 결합합니다.
|
|
403
|
+
- **`setInterceptors(mergeInterceptors, interceptors): void`**
|
|
404
|
+
대상 인터셉터 레지스트리 객체에 새로운 인터셉터 목록을 안전하게 일괄 등록합니다.
|
|
405
|
+
- **`mergeFetchOptions(mergeInterceptors, options): AppFetchOptions`**
|
|
406
|
+
글로벌 인터셉터와 요청별 개별 옵션을 결합합니다.
|
|
407
|
+
- **`HttpError`**
|
|
408
|
+
HTTP 상태 코드(`status`)와 메시지(`message`)를 보존하는 전용 Error 클래스입니다.
|
|
409
|
+
- **`returnError<T = null>(error: unknown): { status: number; message: string; data: T | null }`**
|
|
410
|
+
발생한 예외(Error 및 HttpError) 객체를 안전한 표준 에러 구조체(`{ status, message, data: null }`)로 일괄 변환합니다.
|
|
411
|
+
|
|
412
|
+
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
//#region src/@types/fetch-type.d.ts
|
|
2
|
+
type BeforeRequestInterceptorType = (options: RequestInit) => void | Promise<void>;
|
|
3
|
+
type AfterResponseInterceptorType = (response: Response) => void | Promise<void>;
|
|
4
|
+
type OnErrorType = (error: unknown) => void | Promise<void>;
|
|
5
|
+
interface FetchInterceptors {
|
|
6
|
+
beforeRequest?: BeforeRequestInterceptorType | BeforeRequestInterceptorType[];
|
|
7
|
+
afterResponse?: AfterResponseInterceptorType | AfterResponseInterceptorType[];
|
|
8
|
+
onError?: OnErrorType | OnErrorType[];
|
|
9
|
+
}
|
|
10
|
+
interface RetryContext {
|
|
11
|
+
response?: Response;
|
|
12
|
+
error?: unknown;
|
|
13
|
+
attempt: number;
|
|
14
|
+
maxRetries: number;
|
|
15
|
+
}
|
|
16
|
+
type RetryStrategyFunction = (context: RetryContext) => {
|
|
17
|
+
shouldRetry: boolean;
|
|
18
|
+
delay?: number;
|
|
19
|
+
} | Promise<{
|
|
20
|
+
shouldRetry: boolean;
|
|
21
|
+
delay?: number;
|
|
22
|
+
}>;
|
|
23
|
+
interface RetryStrategyObject {
|
|
24
|
+
shouldRetry: (context: RetryContext) => boolean | Promise<boolean>;
|
|
25
|
+
getDelay?: (context: RetryContext) => number | Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
type RetryStrategy = RetryStrategyFunction | RetryStrategyObject;
|
|
28
|
+
type HttpNoBodyMethod = 'get' | 'delete';
|
|
29
|
+
type HttpBodyMethod = 'post' | 'put' | 'patch';
|
|
30
|
+
type HttpMethod = HttpNoBodyMethod | HttpBodyMethod;
|
|
31
|
+
interface BaseFetchOptions extends FetchInterceptors, Omit<RequestInit, 'method' | 'body'> {
|
|
32
|
+
/** base url */
|
|
33
|
+
baseURL?: string;
|
|
34
|
+
/** request timeout */
|
|
35
|
+
timeout?: number;
|
|
36
|
+
/** if fail retry count */
|
|
37
|
+
retry?: number;
|
|
38
|
+
/** retry delay time */
|
|
39
|
+
delay?: number;
|
|
40
|
+
/** custom retry strategy */
|
|
41
|
+
retryStrategy?: RetryStrategy;
|
|
42
|
+
/** query parameters */
|
|
43
|
+
query?: Record<string, unknown> | object;
|
|
44
|
+
}
|
|
45
|
+
interface QueryFetchOptions extends BaseFetchOptions {
|
|
46
|
+
method?: HttpNoBodyMethod;
|
|
47
|
+
}
|
|
48
|
+
interface BodyFetchOptions extends BaseFetchOptions {
|
|
49
|
+
method: HttpBodyMethod;
|
|
50
|
+
body?: Record<string, unknown> | BodyInit;
|
|
51
|
+
}
|
|
52
|
+
type AppFetchOptions = QueryFetchOptions | BodyFetchOptions;
|
|
53
|
+
interface AppFetchResponse extends Response {
|
|
54
|
+
getData: <T = unknown>() => Promise<T | Blob | FormData | string | null>;
|
|
55
|
+
}
|
|
56
|
+
interface AppFetchPromise extends Promise<AppFetchResponse> {
|
|
57
|
+
getData: <T = unknown>() => Promise<T | Blob | FormData | string | null>;
|
|
58
|
+
}
|
|
59
|
+
type AppFetchInstance = ((path: string, options?: AppFetchOptions) => AppFetchPromise) & {
|
|
60
|
+
create?: (defaults: Omit<AppFetchOptions, 'method' | 'query' | 'body'>) => (path: string, options?: AppFetchOptions) => AppFetchPromise;
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/helpers/fetch-helper.d.ts
|
|
64
|
+
/**
|
|
65
|
+
* @file fetch-helper.ts
|
|
66
|
+
* @description HTTP 응답 데이터 파싱, HTTP 에러 클래스 및 예외 처리 래퍼 헬퍼 함수 모듈입니다.
|
|
67
|
+
* 모든 가용 가능한 Content-Type(JSON 변종, 이미지/오디오/비디오/폰트/문서/압축 파일 등 바이너리, FormData, Plain/HTML/XML/YAML/JS 텍스트)을 지원합니다.
|
|
68
|
+
* 소나큐브 인지 복잡도(Cognitive Complexity) 최소화를 위해 판별 및 파싱 로직이 독립적인 단일 책임 헬퍼 함수로 분리되어 있습니다.
|
|
69
|
+
* @author jaeryeol2
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* HTTP 상태 코드를 포함하는 전용 Error 클래스입니다.
|
|
73
|
+
*
|
|
74
|
+
* @author jaeryeol2
|
|
75
|
+
*/
|
|
76
|
+
declare class HttpError extends Error {
|
|
77
|
+
status: number;
|
|
78
|
+
constructor(message: string, status: number);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 예외 발생 시 표준 HTTP 에러 데이터 구조체 객체({ status, message, data: null })를 반환하는 예외 처리 헬퍼 함수입니다.
|
|
82
|
+
*
|
|
83
|
+
* @template T 반환 데이터의 generic 타입 (기본값: null)
|
|
84
|
+
* @param {unknown} error 발생한 예외 객체 (HttpError, Error 또는 기타 타입)
|
|
85
|
+
* @returns {{ status: number; message: string; data: T | null }} 에러 응답 객체
|
|
86
|
+
* @author jaeryeol2
|
|
87
|
+
*/
|
|
88
|
+
declare const returnError: <T = null>(error: unknown) => {
|
|
89
|
+
status: number;
|
|
90
|
+
message: string;
|
|
91
|
+
data: T | null;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Web Response 객체의 Content-Type 및 응답 상태 코드를 분석하여 적절한 타입으로 데이터를 자동 파싱합니다.
|
|
95
|
+
* JSON 변종, 바이너리 미디어/문서, FormData, Text/XML/Script 등 모든 가용 가능한 Content-Type을 지원합니다.
|
|
96
|
+
*
|
|
97
|
+
* @template T JSON 파싱 시 기대되는 반환 타입
|
|
98
|
+
* @param {Response} response 파싱할 Web Response 인스턴스
|
|
99
|
+
* @returns {Promise<T | Blob | FormData | string | null>} 파싱된 응답 데이터 Promise
|
|
100
|
+
* @author jaeryeol2
|
|
101
|
+
*/
|
|
102
|
+
declare const getData: <T = unknown>(response: Response) => Promise<T | Blob | FormData | string | null>;
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/helpers/fetch-pipeline-helper.d.ts
|
|
105
|
+
/**
|
|
106
|
+
* 지수 백오프(Exponential Backoff) 기반의 재시도 전략 함수를 생성하는 팩토리 헬퍼입니다.
|
|
107
|
+
*
|
|
108
|
+
* @pattern Strategy Pattern - HTTP 상태 코드 및 재시도 시도 횟수에 따른 지수 백오프 지연 알고리즘 전략 캡슐화
|
|
109
|
+
* @param {object} [config] 백오프 설정 (maxRetries, initialDelay, factor, statusCodes)
|
|
110
|
+
* @returns {RetryStrategyFunction} 재시도 전략 함수
|
|
111
|
+
* @author jaeryeol2
|
|
112
|
+
*/
|
|
113
|
+
declare const exponentialBackoffRetry: (config?: {
|
|
114
|
+
maxRetries?: number;
|
|
115
|
+
initialDelay?: number;
|
|
116
|
+
factor?: number;
|
|
117
|
+
statusCodes?: number[];
|
|
118
|
+
}) => RetryStrategyFunction;
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/helpers/interceptor-helper.d.ts
|
|
121
|
+
/**
|
|
122
|
+
* 단일 값 혹은 배열로 전달된 인터셉터를 하나의 안전한 배열로 결합하는 제네릭 헬퍼 함수입니다.
|
|
123
|
+
* 중첩 삼항 연산자를 완전히 제거하고 명시적인 if 분기문으로 가독성 및 린트 준수율을 최적화합니다.
|
|
124
|
+
*
|
|
125
|
+
* @template T 인터셉터 함수 타입
|
|
126
|
+
* @param {T | T[]} [base] 기본 설정 인터셉터 (단일 또는 배열)
|
|
127
|
+
* @param {T | T[]} [custom] 호출 시 개별 전달된 인터셉터 (단일 또는 배열)
|
|
128
|
+
* @returns {T[] | undefined} 체이닝된 인터셉터 배열 또는 undefined
|
|
129
|
+
* @author jaeryeol2
|
|
130
|
+
*/
|
|
131
|
+
declare const composeInterceptors: <T>(base?: T | T[], custom?: T | T[]) => T[] | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* 전역 인터셉터 타겟 객체에 새로운 인터셉터 목록(beforeRequest, afterResponse, onError)을 안전하게 설정합니다.
|
|
134
|
+
*
|
|
135
|
+
* @param {FetchInterceptors} mergeInterceptors 대상 인터셉터 수집 객체
|
|
136
|
+
* @param {FetchInterceptors} interceptors 주입할 인터셉터 객체
|
|
137
|
+
* @author jaeryeol2
|
|
138
|
+
*/
|
|
139
|
+
declare const setInterceptors: (mergeInterceptors: FetchInterceptors, interceptors: FetchInterceptors) => void;
|
|
140
|
+
/**
|
|
141
|
+
* 전역 인터셉터 설정과 요청별 전달된 개별 AppFetchOptions 옵션을 안전하게 결합 및 병합합니다.
|
|
142
|
+
*
|
|
143
|
+
* @param {FetchInterceptors} mergeInterceptors 전역 인터셉터 객체
|
|
144
|
+
* @param {AppFetchOptions} [options] 요청 시 전달된 개별 옵션
|
|
145
|
+
* @returns {AppFetchOptions} 인터셉터가 병합된 최종 AppFetchOptions 객체
|
|
146
|
+
* @author jaeryeol2
|
|
147
|
+
*/
|
|
148
|
+
declare const mergeFetchOptions: (mergeInterceptors: FetchInterceptors, options?: AppFetchOptions) => AppFetchOptions;
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/index.d.ts
|
|
151
|
+
/**
|
|
152
|
+
* 메인 appFetch HTTP 클라이언트 객체입니다.
|
|
153
|
+
* 직접 함수로 호출하거나, appFetch.create()로 커스텀 인스턴스를 생성할 수 있습니다.
|
|
154
|
+
*
|
|
155
|
+
* @author jaeryeol2
|
|
156
|
+
*/
|
|
157
|
+
declare const appFetch: ((path: string, options?: AppFetchOptions, attemptCount?: number) => AppFetchPromise) & {
|
|
158
|
+
create: (defaults: Omit<AppFetchOptions, "method" | "query" | "body">) => (path: string, options?: AppFetchOptions) => AppFetchPromise;
|
|
159
|
+
};
|
|
160
|
+
//#endregion
|
|
161
|
+
export { type AppFetchInstance, type AppFetchOptions, type AppFetchPromise, type AppFetchResponse, type BodyFetchOptions, type HttpBodyMethod, HttpError, type HttpMethod, type HttpNoBodyMethod, type QueryFetchOptions, type RetryContext, type RetryStrategy, type RetryStrategyFunction, type RetryStrategyObject, appFetch, composeInterceptors, exponentialBackoffRetry, getData, mergeFetchOptions, returnError, setInterceptors };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=class e extends Error{status;constructor(t,n){super(t),this.status=n,this.name=`HttpError`,Object.setPrototypeOf(this,e.prototype)}};const t=t=>{let n={status:500,message:`Internal Server Error`,data:null};return t instanceof e?{status:t.status,message:t.message,data:null}:t instanceof Error?{...n,message:t.message}:n},n=[`application/octet-stream`,`pdf`,`zip`,`tar`,`gzip`,`7z`,`rar`,`epub`,`excel`,`word`,`officedocument`,`vnd.ms-`],r=[`application/xml`,`text/xml`,`application/javascript`,`text/javascript`,`application/typescript`,`application/yaml`,`application/graphql`],i=e=>e.status===204||e.status===205||e.headers.get(`content-length`)===`0`,a=e=>e.includes(`json`),o=e=>e.includes(`multipart/`)||e.includes(`form-urlencoded`),s=e=>e.startsWith(`image/`)||e.startsWith(`audio/`)||e.startsWith(`video/`)||e.startsWith(`font/`)?!0:n.some(t=>e.includes(t)),c=e=>e.startsWith(`text/`)?!0:r.some(t=>e.includes(t)),l=Symbol(`app-fetch:not-matched`),u=async(e,t)=>a(t)?await e.json():s(t)?await e.blob():o(t)?await e.formData():c(t)?await e.text():l,d=async e=>{try{return await e.text()}catch{}try{return await e.blob()}catch{return null}},f=async e=>{if(i(e))return null;let t=(e.headers.get(`content-type`)||``).toLowerCase();try{let n=await u(e.clone(),t);if(n!==l)return n}catch(e){console.error(`Content parsing failed, executing fallback.`,e)}return d(e.clone())},p=e=>{let t=e?.maxRetries??3,n=e?.initialDelay??100,r=e?.factor??2,i=e?.statusCodes??[408,429,500,502,503,504];return e=>e.attempt>t||e.response&&!i.includes(e.response.status)?{shouldRetry:!1}:{shouldRetry:!0,delay:n*r**(e.attempt-1)}},m=async(e,t,n)=>{if(!t)return;let r=Array.isArray(t)?t:[t];for(let t of r){if(n?.aborted){let e=Error(`The operation was aborted`);throw e.name=`AbortError`,e}if(n){let r,i=new Promise((e,t)=>{r=()=>{let e=Error(`The operation was aborted`);e.name=`AbortError`,t(e)},n.addEventListener(`abort`,r,{once:!0})});try{await Promise.race([t(e),i])}finally{r&&n.removeEventListener(`abort`,r)}}else await t(e)}},h=async(e,t)=>{if(t){let n=Array.isArray(t)?t:[t];for(let t of n)await t(e.clone())}},g=e=>new Promise(t=>setTimeout(t,e)),_=async(e,t)=>{if(t){let n=Array.isArray(t)?t:[t];for(let t of n)await t(e)}},v=e=>e instanceof FormData||e instanceof Blob||e instanceof URLSearchParams||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||typeof ReadableStream<`u`&&e instanceof ReadableStream||typeof e==`string`,y=(e,t)=>{let n=t?.method?.toLowerCase();if(!(t&&`body`in t&&t.body!==void 0&&t.body!==null)||n!==`post`&&n!==`put`&&n!==`patch`)return;let r=t.body;if(v(r))e.body=r;else{let t=e.headers;t.has(`Content-Type`)||t.set(`Content-Type`,`application/json`),e.body=JSON.stringify(r)}},b=(e,t)=>{if(!e)return t;if(!t)return e;if(`any`in AbortSignal&&typeof AbortSignal.any==`function`)return AbortSignal.any([e,t]);let n=new AbortController,r=()=>n.abort();return e.aborted||t.aborted?n.abort():(e.addEventListener(`abort`,r,{once:!0}),t.addEventListener(`abort`,r,{once:!0})),n.signal},x=e=>{let t=e?.body;return typeof ReadableStream<`u`&&t instanceof ReadableStream},S=async(e,t,n)=>{if(e.attempt>=10)return console.warn(`app-fetch: Retry limit hard cap reached (${e.attempt} attempts). Halting retries to prevent infinite loop.`),{shouldRetry:!1,delay:0};let r=await C(e,t,n);return r.shouldRetry&&x(n)?(console.warn(`app-fetch: Retry skipped because the request body is a ReadableStream, which can only be consumed once and cannot be safely re-sent. To enable retries for this request, provide a re-creatable body instead (e.g. a Blob, ArrayBuffer, string, or FormData).`),{shouldRetry:!1,delay:0}):r},C=async(e,t,n)=>{if(t){if(typeof t==`function`){let n=await t(e);return{shouldRetry:n.shouldRetry,delay:n.delay??0}}if(typeof t==`object`&&t)return{shouldRetry:await t.shouldRetry(e),delay:t.getDelay?await t.getDelay(e):n?.delay??0}}let r=n?.retry??0,i=e.response?.status;return{shouldRetry:(i?i===408||i===429||i>=500&&i<=599:!!e.error)&&e.attempt<=r,delay:n?.delay??0}},w=async(e,t,n,r)=>{let i={...e,baseURL:void 0,query:void 0,beforeRequest:void 0,afterResponse:void 0,onError:void 0,timeout:void 0,retry:void 0,delay:void 0,retryStrategy:void 0};return i.headers=n(e?.headers),y(i,e),i.signal=b(e?.signal,r.signal),await m(i,e?.beforeRequest,i.signal),i},T=async(e,t,n,r,i)=>{let a={response:e.clone(),attempt:r,maxRetries:n?.retry??0},o=await S(a,n?.retryStrategy,n);return o.shouldRetry?(o.delay>0&&await g(o.delay),await i(t,n,r+1)):(await h(e,n?.afterResponse),Object.assign(e,{getData:()=>f(e)}))},E=async(e,t,n,r,i,a)=>{let o=e;if(t&&e instanceof Error&&e.name===`AbortError`&&(o=Error(`Request Timeout. time : ${r?.timeout??3e3}ms`,{cause:e})),r?.signal?.aborted)throw await _(o,r?.onError),o;let s=!!r?.retryStrategy,c=(r?.retry??0)>0;if(s||c){let e={error:o,attempt:i,maxRetries:r?.retry??0},t=await S(e,r?.retryStrategy,r);if(t.shouldRetry)return t.delay>0&&await g(t.delay),await a(n,r,i+1)}throw await _(o,r?.onError),o},D=(e,t)=>{let n=[];e&&(Array.isArray(e)?n.push(...e):n.push(e));let r=[];t&&(Array.isArray(t)?r.push(...t):r.push(t));let i=[...n,...r];return i.length>0?i:void 0},O=(e,t)=>{t.beforeRequest&&(e.beforeRequest=Array.isArray(t.beforeRequest)?t.beforeRequest:[t.beforeRequest]),t.afterResponse&&(e.afterResponse=Array.isArray(t.afterResponse)?t.afterResponse:[t.afterResponse]),t.onError&&(e.onError=Array.isArray(t.onError)?t.onError:[t.onError])},k=(e,t)=>{let n={...t};return n.beforeRequest=D(e.beforeRequest,t?.beforeRequest),n.afterResponse=D(e.afterResponse,t?.afterResponse),n.onError=D(e.onError,t?.onError),n},A=e=>e instanceof Map||e instanceof Set?Array.from(e.values()):Object.values(e),j=(e,t=new WeakSet)=>{if(typeof e!=`object`||!e)return!1;if(t.has(e))return!0;t.add(e);let n=A(e).some(e=>j(e,t));return t.delete(e),n},M=e=>{if(j(e))throw Error(`Circular reference detected in query parameters`);try{return JSON.stringify(e)}catch{return null}},N=e=>e instanceof Map?M(Array.from(e.entries())):e instanceof Set?M(Array.from(e)):e instanceof RegExp?e.toString():M(e),P=(e,t)=>`${encodeURIComponent(e)}=${encodeURIComponent(t)}`,F=(e,t,n,r)=>{if(t==null)return[];if(Array.isArray(t)){if(n.has(t))throw Error(`Circular reference detected in query parameters`);n.add(t);let i=[];for(let[a,o]of t.entries()){let t=`${e}[${a}]`;i.push(...F(t,o,n,r))}return n.delete(t),i}if(typeof t==`object`&&!(t instanceof Date)){if(Object.prototype.toString.call(t)===`[object Object]`)return r(t,e,n);let i=N(t);return i?[P(e,i)]:[]}return t instanceof Date?[P(e,t.toISOString())]:typeof t==`string`||typeof t==`number`||typeof t==`boolean`||typeof t==`bigint`?[P(e,String(t))]:[]},I=(e,t,n,r)=>{if(e instanceof Date)return[P(t,e.toISOString())];if(Object.prototype.toString.call(e)===`[object Object]`)return r(e,t,n);let i=N(e);return i?[P(t,i)]:[]},L=(e,t,n,r)=>{if(n.has(e))throw Error(`Circular reference detected in query parameters`);n.add(e);let i=[];for(let[a,o]of e.entries()){let e=`${t}[${a}]`;i.push(...F(e,o,n,r))}return n.delete(e),i},R=(e,t,n=new WeakSet)=>{let r=[];if(n.has(e))throw Error(`Circular reference detected in query parameters`);n.add(e);for(let[i,a]of Object.entries(e)){if(a==null)continue;let e=t?`${t}.${i}`:i;Array.isArray(a)?r.push(...L(a,e,n,R)):typeof a==`object`?r.push(...I(a,e,n,R)):(typeof a==`string`||typeof a==`number`||typeof a==`boolean`||typeof a==`bigint`)&&r.push(P(e,String(a)))}return n.delete(e),r},z=(e,t=new WeakSet)=>R(e,void 0,t).join(`&`),B=e=>{if(!e)return``;let t=e.length;for(;t>0&&e[t-1]===`/`;)t--;return e.slice(0,t)},V=(e,t)=>{if(/^(?:https?:)?\/\//i.test(e))return e;let n=B(t?.baseURL),r=e.startsWith(`/`)?e:`/${e}`;return n?`${n}${r}`:r},H=(e,t)=>{let n=V(e,t);if(!t?.query)return n;let r=z(t.query);return r?`${n}${n.includes(`?`)?`&`:`?`}${r}`:n},U=(e,t)=>{let n=new Headers(e);if(t)for(let[e,r]of new Headers(t).entries())n.set(e,r);return n},W=(e,t,n=1)=>{let r=(async()=>{let r=null,i=!1,a=new AbortController;try{let o=t?.timeout??3e3;r=o>0?setTimeout(()=>{i=!0,a.abort()},o):null;let s=await w(t,n,U,a),c=H(e,t),l=await fetch(c,s);return r&&clearTimeout(r),await T(l,e,t,n,W)}catch(a){return r&&clearTimeout(r),await E(a,i,e,t,n,W)}})();return Object.assign(r,{getData:()=>r.then(e=>e.getData())})},G=Object.assign(W,{create:e=>(t,n)=>{let r={...e,...n,headers:U(e.headers,n?.headers),beforeRequest:D(e.beforeRequest,n?.beforeRequest),afterResponse:D(e.afterResponse,n?.afterResponse),onError:D(e.onError,n?.onError)};return W(t,r)}});exports.HttpError=e,exports.appFetch=G,exports.composeInterceptors=D,exports.exponentialBackoffRetry=p,exports.getData=f,exports.mergeFetchOptions=k,exports.returnError=t,exports.setInterceptors=O;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var appFetch=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=class e extends Error{status;constructor(t,n){super(t),this.status=n,this.name=`HttpError`,Object.setPrototypeOf(this,e.prototype)}};let n=e=>{let n={status:500,message:`Internal Server Error`,data:null};return e instanceof t?{status:e.status,message:e.message,data:null}:e instanceof Error?{...n,message:e.message}:n},r=[`application/octet-stream`,`pdf`,`zip`,`tar`,`gzip`,`7z`,`rar`,`epub`,`excel`,`word`,`officedocument`,`vnd.ms-`],i=[`application/xml`,`text/xml`,`application/javascript`,`text/javascript`,`application/typescript`,`application/yaml`,`application/graphql`],a=e=>e.status===204||e.status===205||e.headers.get(`content-length`)===`0`,o=e=>e.includes(`json`),s=e=>e.includes(`multipart/`)||e.includes(`form-urlencoded`),c=e=>e.startsWith(`image/`)||e.startsWith(`audio/`)||e.startsWith(`video/`)||e.startsWith(`font/`)?!0:r.some(t=>e.includes(t)),l=e=>e.startsWith(`text/`)?!0:i.some(t=>e.includes(t)),u=Symbol(`app-fetch:not-matched`),d=async(e,t)=>o(t)?await e.json():c(t)?await e.blob():s(t)?await e.formData():l(t)?await e.text():u,f=async e=>{try{return await e.text()}catch{}try{return await e.blob()}catch{return null}},p=async e=>{if(a(e))return null;let t=(e.headers.get(`content-type`)||``).toLowerCase();try{let n=await d(e.clone(),t);if(n!==u)return n}catch(e){console.error(`Content parsing failed, executing fallback.`,e)}return f(e.clone())},m=e=>{let t=e?.maxRetries??3,n=e?.initialDelay??100,r=e?.factor??2,i=e?.statusCodes??[408,429,500,502,503,504];return e=>e.attempt>t||e.response&&!i.includes(e.response.status)?{shouldRetry:!1}:{shouldRetry:!0,delay:n*r**(e.attempt-1)}},h=async(e,t,n)=>{if(!t)return;let r=Array.isArray(t)?t:[t];for(let t of r){if(n?.aborted){let e=Error(`The operation was aborted`);throw e.name=`AbortError`,e}if(n){let r,i=new Promise((e,t)=>{r=()=>{let e=Error(`The operation was aborted`);e.name=`AbortError`,t(e)},n.addEventListener(`abort`,r,{once:!0})});try{await Promise.race([t(e),i])}finally{r&&n.removeEventListener(`abort`,r)}}else await t(e)}},g=async(e,t)=>{if(t){let n=Array.isArray(t)?t:[t];for(let t of n)await t(e.clone())}},_=e=>new Promise(t=>setTimeout(t,e)),v=async(e,t)=>{if(t){let n=Array.isArray(t)?t:[t];for(let t of n)await t(e)}},y=e=>e instanceof FormData||e instanceof Blob||e instanceof URLSearchParams||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||typeof ReadableStream<`u`&&e instanceof ReadableStream||typeof e==`string`,b=(e,t)=>{let n=t?.method?.toLowerCase();if(!(t&&`body`in t&&t.body!==void 0&&t.body!==null)||n!==`post`&&n!==`put`&&n!==`patch`)return;let r=t.body;if(y(r))e.body=r;else{let t=e.headers;t.has(`Content-Type`)||t.set(`Content-Type`,`application/json`),e.body=JSON.stringify(r)}},x=(e,t)=>{if(!e)return t;if(!t)return e;if(`any`in AbortSignal&&typeof AbortSignal.any==`function`)return AbortSignal.any([e,t]);let n=new AbortController,r=()=>n.abort();return e.aborted||t.aborted?n.abort():(e.addEventListener(`abort`,r,{once:!0}),t.addEventListener(`abort`,r,{once:!0})),n.signal},S=e=>{let t=e?.body;return typeof ReadableStream<`u`&&t instanceof ReadableStream},C=async(e,t,n)=>{if(e.attempt>=10)return console.warn(`app-fetch: Retry limit hard cap reached (${e.attempt} attempts). Halting retries to prevent infinite loop.`),{shouldRetry:!1,delay:0};let r=await w(e,t,n);return r.shouldRetry&&S(n)?(console.warn(`app-fetch: Retry skipped because the request body is a ReadableStream, which can only be consumed once and cannot be safely re-sent. To enable retries for this request, provide a re-creatable body instead (e.g. a Blob, ArrayBuffer, string, or FormData).`),{shouldRetry:!1,delay:0}):r},w=async(e,t,n)=>{if(t){if(typeof t==`function`){let n=await t(e);return{shouldRetry:n.shouldRetry,delay:n.delay??0}}if(typeof t==`object`&&t)return{shouldRetry:await t.shouldRetry(e),delay:t.getDelay?await t.getDelay(e):n?.delay??0}}let r=n?.retry??0,i=e.response?.status;return{shouldRetry:(i?i===408||i===429||i>=500&&i<=599:!!e.error)&&e.attempt<=r,delay:n?.delay??0}},T=async(e,t,n,r)=>{let i={...e,baseURL:void 0,query:void 0,beforeRequest:void 0,afterResponse:void 0,onError:void 0,timeout:void 0,retry:void 0,delay:void 0,retryStrategy:void 0};return i.headers=n(e?.headers),b(i,e),i.signal=x(e?.signal,r.signal),await h(i,e?.beforeRequest,i.signal),i},E=async(e,t,n,r,i)=>{let a={response:e.clone(),attempt:r,maxRetries:n?.retry??0},o=await C(a,n?.retryStrategy,n);return o.shouldRetry?(o.delay>0&&await _(o.delay),await i(t,n,r+1)):(await g(e,n?.afterResponse),Object.assign(e,{getData:()=>p(e)}))},D=async(e,t,n,r,i,a)=>{let o=e;if(t&&e instanceof Error&&e.name===`AbortError`&&(o=Error(`Request Timeout. time : ${r?.timeout??3e3}ms`,{cause:e})),r?.signal?.aborted)throw await v(o,r?.onError),o;let s=!!r?.retryStrategy,c=(r?.retry??0)>0;if(s||c){let e={error:o,attempt:i,maxRetries:r?.retry??0},t=await C(e,r?.retryStrategy,r);if(t.shouldRetry)return t.delay>0&&await _(t.delay),await a(n,r,i+1)}throw await v(o,r?.onError),o},O=(e,t)=>{let n=[];e&&(Array.isArray(e)?n.push(...e):n.push(e));let r=[];t&&(Array.isArray(t)?r.push(...t):r.push(t));let i=[...n,...r];return i.length>0?i:void 0},k=(e,t)=>{t.beforeRequest&&(e.beforeRequest=Array.isArray(t.beforeRequest)?t.beforeRequest:[t.beforeRequest]),t.afterResponse&&(e.afterResponse=Array.isArray(t.afterResponse)?t.afterResponse:[t.afterResponse]),t.onError&&(e.onError=Array.isArray(t.onError)?t.onError:[t.onError])},A=(e,t)=>{let n={...t};return n.beforeRequest=O(e.beforeRequest,t?.beforeRequest),n.afterResponse=O(e.afterResponse,t?.afterResponse),n.onError=O(e.onError,t?.onError),n},j=e=>e instanceof Map||e instanceof Set?Array.from(e.values()):Object.values(e),M=(e,t=new WeakSet)=>{if(typeof e!=`object`||!e)return!1;if(t.has(e))return!0;t.add(e);let n=j(e).some(e=>M(e,t));return t.delete(e),n},N=e=>{if(M(e))throw Error(`Circular reference detected in query parameters`);try{return JSON.stringify(e)}catch{return null}},P=e=>e instanceof Map?N(Array.from(e.entries())):e instanceof Set?N(Array.from(e)):e instanceof RegExp?e.toString():N(e),F=(e,t)=>`${encodeURIComponent(e)}=${encodeURIComponent(t)}`,I=(e,t,n,r)=>{if(t==null)return[];if(Array.isArray(t)){if(n.has(t))throw Error(`Circular reference detected in query parameters`);n.add(t);let i=[];for(let[a,o]of t.entries()){let t=`${e}[${a}]`;i.push(...I(t,o,n,r))}return n.delete(t),i}if(typeof t==`object`&&!(t instanceof Date)){if(Object.prototype.toString.call(t)===`[object Object]`)return r(t,e,n);let i=P(t);return i?[F(e,i)]:[]}return t instanceof Date?[F(e,t.toISOString())]:typeof t==`string`||typeof t==`number`||typeof t==`boolean`||typeof t==`bigint`?[F(e,String(t))]:[]},L=(e,t,n,r)=>{if(e instanceof Date)return[F(t,e.toISOString())];if(Object.prototype.toString.call(e)===`[object Object]`)return r(e,t,n);let i=P(e);return i?[F(t,i)]:[]},R=(e,t,n,r)=>{if(n.has(e))throw Error(`Circular reference detected in query parameters`);n.add(e);let i=[];for(let[a,o]of e.entries()){let e=`${t}[${a}]`;i.push(...I(e,o,n,r))}return n.delete(e),i},z=(e,t,n=new WeakSet)=>{let r=[];if(n.has(e))throw Error(`Circular reference detected in query parameters`);n.add(e);for(let[i,a]of Object.entries(e)){if(a==null)continue;let e=t?`${t}.${i}`:i;Array.isArray(a)?r.push(...R(a,e,n,z)):typeof a==`object`?r.push(...L(a,e,n,z)):(typeof a==`string`||typeof a==`number`||typeof a==`boolean`||typeof a==`bigint`)&&r.push(F(e,String(a)))}return n.delete(e),r},B=(e,t=new WeakSet)=>z(e,void 0,t).join(`&`),V=e=>{if(!e)return``;let t=e.length;for(;t>0&&e[t-1]===`/`;)t--;return e.slice(0,t)},H=(e,t)=>{if(/^(?:https?:)?\/\//i.test(e))return e;let n=V(t?.baseURL),r=e.startsWith(`/`)?e:`/${e}`;return n?`${n}${r}`:r},U=(e,t)=>{let n=H(e,t);if(!t?.query)return n;let r=B(t.query);return r?`${n}${n.includes(`?`)?`&`:`?`}${r}`:n},W=(e,t)=>{let n=new Headers(e);if(t)for(let[e,r]of new Headers(t).entries())n.set(e,r);return n},G=(e,t,n=1)=>{let r=(async()=>{let r=null,i=!1,a=new AbortController;try{let o=t?.timeout??3e3;r=o>0?setTimeout(()=>{i=!0,a.abort()},o):null;let s=await T(t,n,W,a),c=U(e,t),l=await fetch(c,s);return r&&clearTimeout(r),await E(l,e,t,n,G)}catch(a){return r&&clearTimeout(r),await D(a,i,e,t,n,G)}})();return Object.assign(r,{getData:()=>r.then(e=>e.getData())})},K=Object.assign(G,{create:e=>(t,n)=>{let r={...e,...n,headers:W(e.headers,n?.headers),beforeRequest:O(e.beforeRequest,n?.beforeRequest),afterResponse:O(e.afterResponse,n?.afterResponse),onError:O(e.onError,n?.onError)};return G(t,r)}});return e.HttpError=t,e.appFetch=K,e.composeInterceptors=O,e.exponentialBackoffRetry=m,e.getData=p,e.mergeFetchOptions=A,e.returnError=n,e.setInterceptors=k,e})({});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=class e extends Error{status;constructor(t,n){super(t),this.status=n,this.name=`HttpError`,Object.setPrototypeOf(this,e.prototype)}};const t=t=>{let n={status:500,message:`Internal Server Error`,data:null};return t instanceof e?{status:t.status,message:t.message,data:null}:t instanceof Error?{...n,message:t.message}:n},n=[`application/octet-stream`,`pdf`,`zip`,`tar`,`gzip`,`7z`,`rar`,`epub`,`excel`,`word`,`officedocument`,`vnd.ms-`],r=[`application/xml`,`text/xml`,`application/javascript`,`text/javascript`,`application/typescript`,`application/yaml`,`application/graphql`],i=e=>e.status===204||e.status===205||e.headers.get(`content-length`)===`0`,a=e=>e.includes(`json`),o=e=>e.includes(`multipart/`)||e.includes(`form-urlencoded`),s=e=>e.startsWith(`image/`)||e.startsWith(`audio/`)||e.startsWith(`video/`)||e.startsWith(`font/`)?!0:n.some(t=>e.includes(t)),c=e=>e.startsWith(`text/`)?!0:r.some(t=>e.includes(t)),l=Symbol(`app-fetch:not-matched`),u=async(e,t)=>a(t)?await e.json():s(t)?await e.blob():o(t)?await e.formData():c(t)?await e.text():l,d=async e=>{try{return await e.text()}catch{}try{return await e.blob()}catch{return null}},f=async e=>{if(i(e))return null;let t=(e.headers.get(`content-type`)||``).toLowerCase();try{let n=await u(e.clone(),t);if(n!==l)return n}catch(e){console.error(`Content parsing failed, executing fallback.`,e)}return d(e.clone())},p=e=>{let t=e?.maxRetries??3,n=e?.initialDelay??100,r=e?.factor??2,i=e?.statusCodes??[408,429,500,502,503,504];return e=>e.attempt>t||e.response&&!i.includes(e.response.status)?{shouldRetry:!1}:{shouldRetry:!0,delay:n*r**(e.attempt-1)}},m=async(e,t,n)=>{if(!t)return;let r=Array.isArray(t)?t:[t];for(let t of r){if(n?.aborted){let e=Error(`The operation was aborted`);throw e.name=`AbortError`,e}if(n){let r,i=new Promise((e,t)=>{r=()=>{let e=Error(`The operation was aborted`);e.name=`AbortError`,t(e)},n.addEventListener(`abort`,r,{once:!0})});try{await Promise.race([t(e),i])}finally{r&&n.removeEventListener(`abort`,r)}}else await t(e)}},h=async(e,t)=>{if(t){let n=Array.isArray(t)?t:[t];for(let t of n)await t(e.clone())}},g=e=>new Promise(t=>setTimeout(t,e)),_=async(e,t)=>{if(t){let n=Array.isArray(t)?t:[t];for(let t of n)await t(e)}},v=e=>e instanceof FormData||e instanceof Blob||e instanceof URLSearchParams||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||typeof ReadableStream<`u`&&e instanceof ReadableStream||typeof e==`string`,y=(e,t)=>{let n=t?.method?.toLowerCase();if(!(t&&`body`in t&&t.body!==void 0&&t.body!==null)||n!==`post`&&n!==`put`&&n!==`patch`)return;let r=t.body;if(v(r))e.body=r;else{let t=e.headers;t.has(`Content-Type`)||t.set(`Content-Type`,`application/json`),e.body=JSON.stringify(r)}},b=(e,t)=>{if(!e)return t;if(!t)return e;if(`any`in AbortSignal&&typeof AbortSignal.any==`function`)return AbortSignal.any([e,t]);let n=new AbortController,r=()=>n.abort();return e.aborted||t.aborted?n.abort():(e.addEventListener(`abort`,r,{once:!0}),t.addEventListener(`abort`,r,{once:!0})),n.signal},x=e=>{let t=e?.body;return typeof ReadableStream<`u`&&t instanceof ReadableStream},S=async(e,t,n)=>{if(e.attempt>=10)return console.warn(`app-fetch: Retry limit hard cap reached (${e.attempt} attempts). Halting retries to prevent infinite loop.`),{shouldRetry:!1,delay:0};let r=await C(e,t,n);return r.shouldRetry&&x(n)?(console.warn(`app-fetch: Retry skipped because the request body is a ReadableStream, which can only be consumed once and cannot be safely re-sent. To enable retries for this request, provide a re-creatable body instead (e.g. a Blob, ArrayBuffer, string, or FormData).`),{shouldRetry:!1,delay:0}):r},C=async(e,t,n)=>{if(t){if(typeof t==`function`){let n=await t(e);return{shouldRetry:n.shouldRetry,delay:n.delay??0}}if(typeof t==`object`&&t)return{shouldRetry:await t.shouldRetry(e),delay:t.getDelay?await t.getDelay(e):n?.delay??0}}let r=n?.retry??0,i=e.response?.status;return{shouldRetry:(i?i===408||i===429||i>=500&&i<=599:!!e.error)&&e.attempt<=r,delay:n?.delay??0}},w=async(e,t,n,r)=>{let i={...e,baseURL:void 0,query:void 0,beforeRequest:void 0,afterResponse:void 0,onError:void 0,timeout:void 0,retry:void 0,delay:void 0,retryStrategy:void 0};return i.headers=n(e?.headers),y(i,e),i.signal=b(e?.signal,r.signal),await m(i,e?.beforeRequest,i.signal),i},T=async(e,t,n,r,i)=>{let a={response:e.clone(),attempt:r,maxRetries:n?.retry??0},o=await S(a,n?.retryStrategy,n);return o.shouldRetry?(o.delay>0&&await g(o.delay),await i(t,n,r+1)):(await h(e,n?.afterResponse),Object.assign(e,{getData:()=>f(e)}))},E=async(e,t,n,r,i,a)=>{let o=e;if(t&&e instanceof Error&&e.name===`AbortError`&&(o=Error(`Request Timeout. time : ${r?.timeout??3e3}ms`,{cause:e})),r?.signal?.aborted)throw await _(o,r?.onError),o;let s=!!r?.retryStrategy,c=(r?.retry??0)>0;if(s||c){let e={error:o,attempt:i,maxRetries:r?.retry??0},t=await S(e,r?.retryStrategy,r);if(t.shouldRetry)return t.delay>0&&await g(t.delay),await a(n,r,i+1)}throw await _(o,r?.onError),o},D=(e,t)=>{let n=[];e&&(Array.isArray(e)?n.push(...e):n.push(e));let r=[];t&&(Array.isArray(t)?r.push(...t):r.push(t));let i=[...n,...r];return i.length>0?i:void 0},O=(e,t)=>{t.beforeRequest&&(e.beforeRequest=Array.isArray(t.beforeRequest)?t.beforeRequest:[t.beforeRequest]),t.afterResponse&&(e.afterResponse=Array.isArray(t.afterResponse)?t.afterResponse:[t.afterResponse]),t.onError&&(e.onError=Array.isArray(t.onError)?t.onError:[t.onError])},k=(e,t)=>{let n={...t};return n.beforeRequest=D(e.beforeRequest,t?.beforeRequest),n.afterResponse=D(e.afterResponse,t?.afterResponse),n.onError=D(e.onError,t?.onError),n},A=e=>e instanceof Map||e instanceof Set?Array.from(e.values()):Object.values(e),j=(e,t=new WeakSet)=>{if(typeof e!=`object`||!e)return!1;if(t.has(e))return!0;t.add(e);let n=A(e).some(e=>j(e,t));return t.delete(e),n},M=e=>{if(j(e))throw Error(`Circular reference detected in query parameters`);try{return JSON.stringify(e)}catch{return null}},N=e=>e instanceof Map?M(Array.from(e.entries())):e instanceof Set?M(Array.from(e)):e instanceof RegExp?e.toString():M(e),P=(e,t)=>`${encodeURIComponent(e)}=${encodeURIComponent(t)}`,F=(e,t,n,r)=>{if(t==null)return[];if(Array.isArray(t)){if(n.has(t))throw Error(`Circular reference detected in query parameters`);n.add(t);let i=[];for(let[a,o]of t.entries()){let t=`${e}[${a}]`;i.push(...F(t,o,n,r))}return n.delete(t),i}if(typeof t==`object`&&!(t instanceof Date)){if(Object.prototype.toString.call(t)===`[object Object]`)return r(t,e,n);let i=N(t);return i?[P(e,i)]:[]}return t instanceof Date?[P(e,t.toISOString())]:typeof t==`string`||typeof t==`number`||typeof t==`boolean`||typeof t==`bigint`?[P(e,String(t))]:[]},I=(e,t,n,r)=>{if(e instanceof Date)return[P(t,e.toISOString())];if(Object.prototype.toString.call(e)===`[object Object]`)return r(e,t,n);let i=N(e);return i?[P(t,i)]:[]},L=(e,t,n,r)=>{if(n.has(e))throw Error(`Circular reference detected in query parameters`);n.add(e);let i=[];for(let[a,o]of e.entries()){let e=`${t}[${a}]`;i.push(...F(e,o,n,r))}return n.delete(e),i},R=(e,t,n=new WeakSet)=>{let r=[];if(n.has(e))throw Error(`Circular reference detected in query parameters`);n.add(e);for(let[i,a]of Object.entries(e)){if(a==null)continue;let e=t?`${t}.${i}`:i;Array.isArray(a)?r.push(...L(a,e,n,R)):typeof a==`object`?r.push(...I(a,e,n,R)):(typeof a==`string`||typeof a==`number`||typeof a==`boolean`||typeof a==`bigint`)&&r.push(P(e,String(a)))}return n.delete(e),r},z=(e,t=new WeakSet)=>R(e,void 0,t).join(`&`),B=e=>{if(!e)return``;let t=e.length;for(;t>0&&e[t-1]===`/`;)t--;return e.slice(0,t)},V=(e,t)=>{if(/^(?:https?:)?\/\//i.test(e))return e;let n=B(t?.baseURL),r=e.startsWith(`/`)?e:`/${e}`;return n?`${n}${r}`:r},H=(e,t)=>{let n=V(e,t);if(!t?.query)return n;let r=z(t.query);return r?`${n}${n.includes(`?`)?`&`:`?`}${r}`:n},U=(e,t)=>{let n=new Headers(e);if(t)for(let[e,r]of new Headers(t).entries())n.set(e,r);return n},W=(e,t,n=1)=>{let r=(async()=>{let r=null,i=!1,a=new AbortController;try{let o=t?.timeout??3e3;r=o>0?setTimeout(()=>{i=!0,a.abort()},o):null;let s=await w(t,n,U,a),c=H(e,t),l=await fetch(c,s);return r&&clearTimeout(r),await T(l,e,t,n,W)}catch(a){return r&&clearTimeout(r),await E(a,i,e,t,n,W)}})();return Object.assign(r,{getData:()=>r.then(e=>e.getData())})},G=Object.assign(W,{create:e=>(t,n)=>{let r={...e,...n,headers:U(e.headers,n?.headers),beforeRequest:D(e.beforeRequest,n?.beforeRequest),afterResponse:D(e.afterResponse,n?.afterResponse),onError:D(e.onError,n?.onError)};return W(t,r)}});export{e as HttpError,G as appFetch,D as composeInterceptors,p as exponentialBackoffRetry,f as getData,k as mergeFetchOptions,t as returnError,O as setInterceptors};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "app-fetch",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "custom fetch library",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "jaeryeol2",
|
|
7
|
+
"private": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jaeryeol2/app-fetch.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jaeryeol2/app-fetch#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jaeryeol2/app-fetch/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/app-fetch.cjs",
|
|
21
|
+
"module": "./dist/app-fetch.mjs",
|
|
22
|
+
"types": "./dist/@types/app-fetch.d.mts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/@types/app-fetch.d.mts",
|
|
31
|
+
"import": "./dist/app-fetch.mjs",
|
|
32
|
+
"require": "./dist/app-fetch.cjs",
|
|
33
|
+
"script": "./dist/app-fetch.min.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown && node -e \"fs.renameSync('dist/app-fetch.min.iife.js', 'dist/app-fetch.min.js')\"",
|
|
38
|
+
"prepare": "npm run build",
|
|
39
|
+
"pretest": "npm run build",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"lint": "eslint src tests"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^10.0.1",
|
|
46
|
+
"@types/node": "^26.0.0",
|
|
47
|
+
"eslint": "^10.5.0",
|
|
48
|
+
"eslint-config-prettier": "^10.1.8",
|
|
49
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
50
|
+
"globals": "^17.7.0",
|
|
51
|
+
"happy-dom": "^20.11.1",
|
|
52
|
+
"tsdown": "^0.22.14",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"typescript-eslint": "^8.61.1",
|
|
55
|
+
"vitest": "^4.1.10"
|
|
56
|
+
}
|
|
57
|
+
}
|