├── .editorconfig ├── .github ├── banner.svg └── workflows │ ├── autofix.yml │ └── ci.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.config.ts ├── eslint.config.mjs ├── examples ├── README.md ├── body.mjs ├── error-handling.mjs ├── first-request.mjs ├── headers.mjs ├── methods.mjs ├── proxy.mjs ├── query-string.mjs └── type-safety.ts ├── node.d.ts ├── package.json ├── playground ├── index.mjs └── index.ts ├── pnpm-lock.yaml ├── renovate.json ├── src ├── base.ts ├── error.ts ├── fetch.ts ├── index.ts ├── node.ts ├── types.ts └── utils.ts ├── test └── index.test.ts ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: { pull_request: {}, push: { branches: ["main"] } } 4 | 5 | permissions: { contents: read } 6 | 7 | jobs: 8 | autofix: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v5 12 | - run: corepack enable 13 | - uses: actions/setup-node@v4 14 | with: { node-version: lts/*, cache: "pnpm" } 15 | - run: pnpm install 16 | - run: pnpm run lint:fix 17 | - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 18 | with: { commit-message: "chore: apply automated updates" } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: { push: { branches: [main] }, pull_request: { branches: [main] } } 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: { node: [18, 20, 22, 24] } 10 | steps: 11 | - uses: actions/checkout@v5 12 | - run: corepack enable 13 | - uses: actions/setup-node@v4 14 | with: { node-version: "${{ matrix.node }}", cache: "pnpm" } 15 | - run: pnpm install 16 | - run: pnpm lint 17 | - run: pnpm build 18 | - run: pnpm vitest --coverage 19 | - uses: codecov/codecov-action@v5 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | *.log 4 | .DS_Store 5 | coverage 6 | dist 7 | types 8 | .conf* 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## v1.4.1 6 | 7 | [compare changes](https://github.com/unjs/ofetch/compare/v1.4.0...v1.4.1) 8 | 9 | ### 🩹 Fixes 10 | 11 | - Remove undefined `method` and `query`/`params` from fetch options ([#451](https://github.com/unjs/ofetch/pull/451)) 12 | - Use `response._bodyInit` as fallback for react-native and qq ([#398](https://github.com/unjs/ofetch/pull/398)) 13 | 14 | ### 🏡 Chore 15 | 16 | - **examples:** Fix typos ([#450](https://github.com/unjs/ofetch/pull/450)) 17 | - Update dependencies ([caaf04d](https://github.com/unjs/ofetch/commit/caaf04d)) 18 | - Update eslint config ([b4c9990](https://github.com/unjs/ofetch/commit/b4c9990)) 19 | 20 | ### ✅ Tests 21 | 22 | - Fix typo ([#448](https://github.com/unjs/ofetch/pull/448)) 23 | 24 | ### ❤️ Contributors 25 | 26 | - Joshua Sosso ([@joshmossas](http://github.com/joshmossas)) 27 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 28 | - @beer ([@iiio2](http://github.com/iiio2)) 29 | - Cooper Roper 30 | 31 | ## v1.4.0 32 | 33 | [compare changes](https://github.com/unjs/ofetch/compare/v1.3.4...v1.4.0) 34 | 35 | ### 🚀 Enhancements 36 | 37 | - Support `retryDelay` with callback function ([#372](https://github.com/unjs/ofetch/pull/372)) 38 | - Add better message and code for timeout error ([#351](https://github.com/unjs/ofetch/pull/351)) 39 | - Allow custom global options for `$fetch.create` ([#401](https://github.com/unjs/ofetch/pull/401)) 40 | - Support interceptors arrays ([#353](https://github.com/unjs/ofetch/pull/353)) 41 | - Always clone and normalize `options.headers` and `options.query` ([#436](https://github.com/unjs/ofetch/pull/436)) 42 | 43 | ### 🩹 Fixes 44 | 45 | - Export types from `node` export condition ([#407](https://github.com/unjs/ofetch/pull/407)) 46 | - Use wrapper to allow patching global `fetch` ([#377](https://github.com/unjs/ofetch/pull/377)) 47 | 48 | ### 📖 Documentation 49 | 50 | - Add docs for using undici dispatcher ([#389](https://github.com/unjs/ofetch/pull/389)) 51 | 52 | ### 🌊 Types 53 | 54 | - Add `agent` and `dispatcher` options (node-specific) ([#308](https://github.com/unjs/ofetch/pull/308)) 55 | 56 | ### 🏡 Chore 57 | 58 | - **release:** V1.3.4 ([5cc16a0](https://github.com/unjs/ofetch/commit/5cc16a0)) 59 | - Remove extra space ([#384](https://github.com/unjs/ofetch/pull/384)) 60 | - Update deps ([509a037](https://github.com/unjs/ofetch/commit/509a037)) 61 | - Update to eslint v9 ([e63c598](https://github.com/unjs/ofetch/commit/e63c598)) 62 | - Apply automated fixes ([f8f5413](https://github.com/unjs/ofetch/commit/f8f5413)) 63 | - Add back spoiler ([dba1915](https://github.com/unjs/ofetch/commit/dba1915)) 64 | - Add experimental for `Too Early` status ([#426](https://github.com/unjs/ofetch/pull/426)) 65 | - Update dependencies ([b5fe505](https://github.com/unjs/ofetch/commit/b5fe505)) 66 | - Update deps ([20f67b9](https://github.com/unjs/ofetch/commit/20f67b9)) 67 | 68 | ### ✅ Tests 69 | 70 | - Add additional tests for hook errors ([7ff4d11](https://github.com/unjs/ofetch/commit/7ff4d11)) 71 | 72 | ### 🤖 CI 73 | 74 | - Update node version ([4faac04](https://github.com/unjs/ofetch/commit/4faac04)) 75 | - Update autifix ([79483ab](https://github.com/unjs/ofetch/commit/79483ab)) 76 | 77 | ### ❤️ Contributors 78 | 79 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 80 | - Antoine Rey 81 | - Cafu Chino 82 | - Marco Solazzi 83 | - @beer ([@iiio2](http://github.com/iiio2)) 84 | - Daniel Roe ([@danielroe](http://github.com/danielroe)) 85 | - Arlo 86 | - Alexander Topalo 87 | - Sam Blowes 88 | - Kongmoumou ([@kongmoumou](http://github.com/kongmoumou)) 89 | 90 | ## v1.3.4 91 | 92 | [compare changes](https://github.com/unjs/ofetch/compare/v1.3.3...v1.3.4) 93 | 94 | ### 🚀 Enhancements 95 | 96 | - Export all types ([#280](https://github.com/unjs/ofetch/pull/280)) 97 | - Expose `GlobalOptions` type ([#307](https://github.com/unjs/ofetch/pull/307)) 98 | 99 | ### 🩹 Fixes 100 | 101 | - Clear abort timeout after response was received ([#369](https://github.com/unjs/ofetch/pull/369)) 102 | 103 | ### 💅 Refactors 104 | 105 | - Remove extra line ([#374](https://github.com/unjs/ofetch/pull/374)) 106 | 107 | ### 📖 Documentation 108 | 109 | - Add initial examples ([#288](https://github.com/unjs/ofetch/pull/288)) 110 | 111 | ### 📦 Build 112 | 113 | - Add top level `react-native` field ([03680dd](https://github.com/unjs/ofetch/commit/03680dd)) 114 | 115 | ### 🏡 Chore 116 | 117 | - **release:** V1.3.3 ([31c61c1](https://github.com/unjs/ofetch/commit/31c61c1)) 118 | - Update dependencies ([308f03f](https://github.com/unjs/ofetch/commit/308f03f)) 119 | - Ignore conflicting ts error for now ([3a73165](https://github.com/unjs/ofetch/commit/3a73165)) 120 | - Improve docs ([173d5b9](https://github.com/unjs/ofetch/commit/173d5b9)) 121 | - Remove lagon ([#346](https://github.com/unjs/ofetch/pull/346)) 122 | - Update lockfile ([4b6d1ba](https://github.com/unjs/ofetch/commit/4b6d1ba)) 123 | - Fix build error ([472c4d9](https://github.com/unjs/ofetch/commit/472c4d9)) 124 | - Update node-fetch-native ([fa2cc07](https://github.com/unjs/ofetch/commit/fa2cc07)) 125 | 126 | ### ❤️ Contributors 127 | 128 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 129 | - Alex Liu ([@Mini-ghost](http://github.com/Mini-ghost)) 130 | - Danila Rodichkin ([@daniluk4000](http://github.com/daniluk4000)) 131 | - Maxime Pauvert ([@maximepvrt](http://github.com/maximepvrt)) 132 | - Estéban ([@Barbapapazes](http://github.com/Barbapapazes)) 133 | - Saman 134 | 135 | ## v1.3.3 136 | 137 | [compare changes](https://github.com/unjs/ofetch/compare/v1.3.2...v1.3.3) 138 | 139 | ### 🩹 Fixes 140 | 141 | - Augment `FetchError` type to include `IFetchError` ([#279](https://github.com/unjs/ofetch/pull/279)) 142 | 143 | ### ❤️ Contributors 144 | 145 | - Johann Schopplich ([@johannschopplich](http://github.com/johannschopplich)) 146 | 147 | ## v1.3.2 148 | 149 | [compare changes](https://github.com/unjs/ofetch/compare/v1.3.1...v1.3.2) 150 | 151 | ### 🩹 Fixes 152 | 153 | - Hide getters from console and pass `cause` ([905244a](https://github.com/unjs/ofetch/commit/905244a)) 154 | 155 | ### ❤️ Contributors 156 | 157 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 158 | 159 | ## v1.3.1 160 | 161 | [compare changes](https://github.com/unjs/ofetch/compare/v1.3.0...v1.3.1) 162 | 163 | ### 🏡 Chore 164 | 165 | - Update dependencies ([c72976f](https://github.com/unjs/ofetch/commit/c72976f)) 166 | 167 | ### ❤️ Contributors 168 | 169 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 170 | 171 | ## v1.3.0 172 | 173 | [compare changes](https://github.com/unjs/ofetch/compare/v1.2.1...v1.3.0) 174 | 175 | ### 🚀 Enhancements 176 | 177 | - Support customizable `retryStatusCodes` ([#109](https://github.com/unjs/ofetch/pull/109)) 178 | - Add `options` field and improve formatting of errors ([#270](https://github.com/unjs/ofetch/pull/270)) 179 | - Automatically enable duplex to stream request body ([#275](https://github.com/unjs/ofetch/pull/275)) 180 | 181 | ### 🩹 Fixes 182 | 183 | - Avoid binding `.native` to `$fetch` ([#272](https://github.com/unjs/ofetch/pull/272)) 184 | - Skip reading body with `204` responses and `HEAD` requests ([#171](https://github.com/unjs/ofetch/pull/171), [#84](https://github.com/unjs/ofetch/pull/84)) 185 | - Improve response body check for node 16 compatibility ([64d3aed](https://github.com/unjs/ofetch/commit/64d3aed)) 186 | - Avoid serializing buffer body ([#273](https://github.com/unjs/ofetch/pull/273)) 187 | - Move body handling out of request block ([15a28fb](https://github.com/unjs/ofetch/commit/15a28fb)) 188 | 189 | ### 💅 Refactors 190 | 191 | - Remove unused `response?: boolean` option ([#223](https://github.com/unjs/ofetch/pull/223)) 192 | - Pass all fetch context to the error ([b70e6b0](https://github.com/unjs/ofetch/commit/b70e6b0)) 193 | - **error:** Factory pattern for getters ([6139785](https://github.com/unjs/ofetch/commit/6139785)) 194 | 195 | ### 📖 Documentation 196 | 197 | - Improve explanation about `body` option ([#276](https://github.com/unjs/ofetch/pull/276)) 198 | 199 | ### 🏡 Chore 200 | 201 | - **release:** V1.2.1 ([bb98cb5](https://github.com/unjs/ofetch/commit/bb98cb5)) 202 | - Remove accidental `raw` response type addition ([8589cae](https://github.com/unjs/ofetch/commit/8589cae)) 203 | 204 | ### ❤️ Contributors 205 | 206 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 207 | - Nozomu Ikuta 208 | - Daniil Bezuglov 209 | 210 | ## v1.2.1 211 | 212 | [compare changes](https://github.com/unjs/ofetch/compare/v1.2.0...v1.2.1) 213 | 214 | ### 📦 Build 215 | 216 | - Add missing `node` export condition ([4081170](https://github.com/unjs/ofetch/commit/4081170)) 217 | 218 | ### 🏡 Chore 219 | 220 | - Update dependencies ([d18584d](https://github.com/unjs/ofetch/commit/d18584d)) 221 | 222 | ### ✅ Tests 223 | 224 | - Speedup with background close ([567fb35](https://github.com/unjs/ofetch/commit/567fb35)) 225 | 226 | ### ❤️ Contributors 227 | 228 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 229 | 230 | ## v1.2.0 231 | 232 | [compare changes](https://github.com/unjs/ofetch/compare/v1.1.1...v1.2.0) 233 | 234 | ### 🚀 Enhancements 235 | 236 | - Support `retryDelay` ([#262](https://github.com/unjs/ofetch/pull/262)) 237 | - Support `timeout` and `AbortController` ([#268](https://github.com/unjs/ofetch/pull/268)) 238 | 239 | ### 🩹 Fixes 240 | 241 | - Always uppercase `method` option ([#259](https://github.com/unjs/ofetch/pull/259)) 242 | - **pkg:** Fix ts type resolution for `/node` subpath ([#256](https://github.com/unjs/ofetch/pull/256)) 243 | - Make all `createFetch` options optional ([#266](https://github.com/unjs/ofetch/pull/266)) 244 | 245 | ### 📖 Documentation 246 | 247 | - Clarify retry behavior ([#264](https://github.com/unjs/ofetch/pull/264)) 248 | - Fix typo ([de66aad](https://github.com/unjs/ofetch/commit/de66aad)) 249 | 250 | ### 🏡 Chore 251 | 252 | - Update dev dependencies ([8fc7d96](https://github.com/unjs/ofetch/commit/8fc7d96)) 253 | - **release:** V1.1.1 ([41c3b56](https://github.com/unjs/ofetch/commit/41c3b56)) 254 | - Update dependencies ([db2434c](https://github.com/unjs/ofetch/commit/db2434c)) 255 | - Add autofix ci ([a953a33](https://github.com/unjs/ofetch/commit/a953a33)) 256 | - Apply automated fixes ([bbdfb9c](https://github.com/unjs/ofetch/commit/bbdfb9c)) 257 | 258 | ### ✅ Tests 259 | 260 | - Update tests ([db2ad50](https://github.com/unjs/ofetch/commit/db2ad50)) 261 | 262 | ### 🎨 Styles 263 | 264 | - Lint code ([b3c6a96](https://github.com/unjs/ofetch/commit/b3c6a96)) 265 | - Lint repo with prettier ([2be558c](https://github.com/unjs/ofetch/commit/2be558c)) 266 | 267 | ### ❤️ Contributors 268 | 269 | - Daniil Bezuglov 270 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 271 | - Sébastien Chopin ([@Atinux](http://github.com/Atinux)) 272 | - Tmk ([@tmkx](http://github.com/tmkx)) 273 | - Murisceman 274 | - Heb ([@Hebilicious](http://github.com/Hebilicious)) 275 | 276 | ## v1.1.1 277 | 278 | [compare changes](https://github.com/unjs/ofetch/compare/v1.1.0...v1.1.1) 279 | 280 | 281 | ### 🏡 Chore 282 | 283 | - Update dev dependencies ([8fc7d96](https://github.com/unjs/ofetch/commit/8fc7d96)) 284 | 285 | ### ❤️ Contributors 286 | 287 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 288 | 289 | ## v1.1.0 290 | 291 | [compare changes](https://github.com/unjs/ofetch/compare/v1.0.1...v1.1.0) 292 | 293 | 294 | ### 🚀 Enhancements 295 | 296 | - Support `ignoreResponseError` option ([#221](https://github.com/unjs/ofetch/pull/221)) 297 | - **pkg:** Add export conditions for runtime keys ([#246](https://github.com/unjs/ofetch/pull/246)) 298 | 299 | ### 🩹 Fixes 300 | 301 | - Pass empty object to headers initializer to prevent crash on chrome 49 ([#235](https://github.com/unjs/ofetch/pull/235)) 302 | - Export `ResponseMap` type to allow composition of `ofetch` ([#232](https://github.com/unjs/ofetch/pull/232)) 303 | - Fix issues with native node fetch ([#245](https://github.com/unjs/ofetch/pull/245)) 304 | - **pkg:** Add `./package.json` subpath ([253707a](https://github.com/unjs/ofetch/commit/253707a)) 305 | - Deep merge fetch options ([#243](https://github.com/unjs/ofetch/pull/243)) 306 | 307 | ### 📖 Documentation 308 | 309 | - **readme:** Use `_data` rather than `data` for raw requests ([#239](https://github.com/unjs/ofetch/pull/239)) 310 | - Mention `DELETE` is no-retry be default ([#241](https://github.com/unjs/ofetch/pull/241)) 311 | 312 | ### 🏡 Chore 313 | 314 | - **readme:** Small improvements ([65921a1](https://github.com/unjs/ofetch/commit/65921a1)) 315 | 316 | ### 🤖 CI 317 | 318 | - Enable tests against node `16`, `18` and `20` ([351fc80](https://github.com/unjs/ofetch/commit/351fc80)) 319 | 320 | ### ❤️ Contributors 321 | 322 | - Dennis Meuwissen 323 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 324 | - Alex Korytskyi ([@alex-key](http://github.com/alex-key)) 325 | - Arunanshu Biswas 326 | - Jonathan Bakebwa 327 | - Ilya Semenov ([@IlyaSemenov](http://github.com/IlyaSemenov)) 328 | - _lmmmmmm 329 | - Jonas Thelemann ([@dargmuesli](http://github.com/dargmuesli)) 330 | - Sébastien Chopin 331 | 332 | ## v1.0.1 333 | 334 | [compare changes](https://github.com/unjs/ofetch/compare/v1.0.0...v1.0.1) 335 | 336 | 337 | ### 🩹 Fixes 338 | 339 | - Improve error message for request errors ([#199](https://github.com/unjs/ofetch/pull/199)) 340 | 341 | ### 📖 Documentation 342 | 343 | - Fix small typos ([#200](https://github.com/unjs/ofetch/pull/200)) 344 | - Fix typo ([#175](https://github.com/unjs/ofetch/pull/175)) 345 | - Add agent option usage ([#173](https://github.com/unjs/ofetch/pull/173)) 346 | - Add note about http agent ([#202](https://github.com/unjs/ofetch/pull/202)) 347 | 348 | ### 📦 Build 349 | 350 | - Use standalone commonjs dist ([#211](https://github.com/unjs/ofetch/pull/211)) 351 | 352 | ### 🏡 Chore 353 | 354 | - Update lockfile ([67a7fa4](https://github.com/unjs/ofetch/commit/67a7fa4)) 355 | - Remove build badge ([9a878b6](https://github.com/unjs/ofetch/commit/9a878b6)) 356 | - Update ufo ([3776210](https://github.com/unjs/ofetch/commit/3776210)) 357 | - Update release script ([50a58ab](https://github.com/unjs/ofetch/commit/50a58ab)) 358 | 359 | ### 🎨 Styles 360 | 361 | - Format with prettier ([aabfb9a](https://github.com/unjs/ofetch/commit/aabfb9a)) 362 | 363 | ### ❤️ Contributors 364 | 365 | - Pooya Parsa 366 | - Daniel West 367 | - Sébastien Chopin 368 | - Nozomu Ikuta 369 | - Yuyin 370 | - Kricsleo 371 | - 0xflotus <0xflotus@gmail.com> 372 | 373 | ## [1.0.0](https://github.com/unjs/ofetch/compare/v0.4.21...v1.0.0) (2022-11-15) 374 | 375 | 376 | ### ⚠ BREAKING CHANGES 377 | 378 | * drop undici support 379 | 380 | ### Features 381 | 382 | * expose `$fetch.native` ([ff697d7](https://github.com/unjs/ofetch/commit/ff697d7b4a43c4399897b69097485b3785dfd661)) 383 | * expose `ofetch` named export ([d8fc46f](https://github.com/unjs/ofetch/commit/d8fc46f21a51f0aac75118905fb999a62d46c793)) 384 | 385 | 386 | * drop undici support ([c7d8c93](https://github.com/unjs/ofetch/commit/c7d8c93b1dc9af6f556b713d63787d4295709908)) 387 | 388 | ### [0.4.21](https://github.com/unjs/ofetch/compare/v0.4.20...v0.4.21) (2022-11-03) 389 | 390 | 391 | ### Features 392 | 393 | * add `status` and `statusText` to fetch errors ([#152](https://github.com/unjs/ofetch/issues/152)) ([784a7c0](https://github.com/unjs/ofetch/commit/784a7c0524a60406b0ba09055502107ef57ef5c9)) 394 | 395 | 396 | ### Bug Fixes 397 | 398 | * only call error handler if status code is >= 400 ([#153](https://github.com/unjs/ofetch/issues/153)) ([385f7fe](https://github.com/unjs/ofetch/commit/385f7fe9c92d1ee614919c0ce3a9586acf031d05)) 399 | 400 | ### [0.4.20](https://github.com/unjs/ofetch/compare/v0.4.19...v0.4.20) (2022-10-17) 401 | 402 | 403 | ### Bug Fixes 404 | 405 | * add backwards-compatible subpath declarations ([#144](https://github.com/unjs/ofetch/issues/144)) ([3a48c21](https://github.com/unjs/ofetch/commit/3a48c21a0a5deb41be7ca8e9ea176c49e6d6ba56)) 406 | * **types:** allow synchronous interceptors to be passed ([#128](https://github.com/unjs/ofetch/issues/128)) ([46e8f3c](https://github.com/unjs/ofetch/commit/46e8f3c70bbae09b123db42ab868631cdf9a45af)) 407 | 408 | ### [0.4.19](https://github.com/unjs/ofetch/compare/v0.4.18...v0.4.19) (2022-09-19) 409 | 410 | 411 | ### Features 412 | 413 | * support `responseType: 'stream'` as `ReadableStream` ([#100](https://github.com/unjs/ofetch/issues/100)) ([5a19f73](https://github.com/unjs/ofetch/commit/5a19f73e97b13b6679ee2c75ad10b87480599d9b)) 414 | 415 | 416 | ### Bug Fixes 417 | 418 | * do not retry when fetch is aborted ([#112](https://github.com/unjs/ofetch/issues/112)) ([b73fe67](https://github.com/unjs/ofetch/commit/b73fe67aaa7d5d5b1a283b653e7cd7fb30a4cc21)) 419 | 420 | ### [0.4.18](https://github.com/unjs/ofetch/compare/v0.4.17...v0.4.18) (2022-05-20) 421 | 422 | 423 | ### Bug Fixes 424 | 425 | * only serialize JSON bodies ([#80](https://github.com/unjs/ofetch/issues/80)) ([dc237d4](https://github.com/unjs/ofetch/commit/dc237d489a68936996c9ab95dce35ca9e0d2b2e4)) 426 | 427 | ### [0.4.17](https://github.com/unjs/ofetch/compare/v0.4.16...v0.4.17) (2022-05-11) 428 | 429 | 430 | ### Features 431 | 432 | * use `node-fetch-native` ([a881acb](https://github.com/unjs/ofetch/commit/a881acb4067406092e25a1f97ff576040e279bce)) 433 | 434 | ### [0.4.16](https://github.com/unjs/ofetch/compare/v0.4.15...v0.4.16) (2022-04-29) 435 | 436 | 437 | ### Bug Fixes 438 | 439 | * generalise `application/json` content types ([#75](https://github.com/unjs/ofetch/issues/75)) ([adaa03b](https://github.com/unjs/ofetch/commit/adaa03b56bf17df2a9a69eb471e84460e0d1ad12)) 440 | 441 | ### [0.4.15](https://github.com/unjs/ofetch/compare/v0.4.14...v0.4.15) (2022-01-18) 442 | 443 | 444 | ### Bug Fixes 445 | 446 | * use `_data` rather than `data` to store deserialized response ([#49](https://github.com/unjs/ofetch/issues/49)) ([babb331](https://github.com/unjs/ofetch/commit/babb331c3c2dc52d682f87b3c2c660d23cf056b3)) 447 | 448 | ### [0.4.14](https://github.com/unjs/ofetch/compare/v0.4.13...v0.4.14) (2021-12-22) 449 | 450 | 451 | ### Bug Fixes 452 | 453 | * avoid calling `fetch` with `globalOptions` context ([8ea2d2b](https://github.com/unjs/ofetch/commit/8ea2d2b5f9dcda333de5824862a7377085fd8bb4)) 454 | 455 | ### [0.4.13](https://github.com/unjs/ofetch/compare/v0.4.12...v0.4.13) (2021-12-21) 456 | 457 | 458 | ### Features 459 | 460 | * `$fetch.create` support ([d7fb8f6](https://github.com/unjs/ofetch/commit/d7fb8f688c2b3f574430b40f7139ee144850be23)) 461 | * initial interceptor support (resolves [#19](https://github.com/unjs/ofetch/issues/19)) ([1bf2dd9](https://github.com/unjs/ofetch/commit/1bf2dd928935b79b13a4e7a8fbd93365b082835f)) 462 | 463 | ### [0.4.12](https://github.com/unjs/ofetch/compare/v0.4.11...v0.4.12) (2021-12-21) 464 | 465 | 466 | ### Bug Fixes 467 | 468 | * avoid overriding headers ([4b74e45](https://github.com/unjs/ofetch/commit/4b74e45f9989a993e725e7fe4d2e098442e457f1)), closes [#40](https://github.com/unjs/ofetch/issues/40) [#41](https://github.com/unjs/ofetch/issues/41) 469 | * only retry on known response codes (resolves [#31](https://github.com/unjs/ofetch/issues/31)) ([f7fff24](https://github.com/unjs/ofetch/commit/f7fff24acfde76029051fe26a88f993518a95735)) 470 | 471 | ### [0.4.11](https://github.com/unjs/ofetch/compare/v0.4.10...v0.4.11) (2021-12-17) 472 | 473 | 474 | ### Features 475 | 476 | * return blob if `content-type` isn't text, svg, xml or json ([#39](https://github.com/unjs/ofetch/issues/39)) ([1029b9e](https://github.com/unjs/ofetch/commit/1029b9e2c982991d21d77ea33036d7c20a4536bb)) 477 | 478 | ### [0.4.10](https://github.com/unjs/ofetch/compare/v0.4.9...v0.4.10) (2021-12-14) 479 | 480 | 481 | ### Bug Fixes 482 | 483 | * avoid optional chaining ([931d12d](https://github.com/unjs/ofetch/commit/931d12d945d5bc014b34f46d93a627dbb4eda3a7)) 484 | 485 | ### [0.4.9](https://github.com/unjs/ofetch/compare/v0.4.8...v0.4.9) (2021-12-14) 486 | 487 | 488 | ### Features 489 | 490 | * improve json body handling ([4adb3bc](https://github.com/unjs/ofetch/commit/4adb3bc38d9423507ccdcd895b62a3a7d95f4144)), closes [#36](https://github.com/unjs/ofetch/issues/36) 491 | 492 | ### [0.4.8](https://github.com/unjs/ofetch/compare/v0.4.7...v0.4.8) (2021-11-22) 493 | 494 | 495 | ### Bug Fixes 496 | 497 | * add accept header when using json payload ([#30](https://github.com/unjs/ofetch/issues/30)) ([662145f](https://github.com/unjs/ofetch/commit/662145f8b74a18ba7d07e8eb6f3fd1af91941a22)) 498 | 499 | ### [0.4.7](https://github.com/unjs/ofetch/compare/v0.4.6...v0.4.7) (2021-11-18) 500 | 501 | 502 | ### Bug Fixes 503 | 504 | * use `application/json` for array body ([#29](https://github.com/unjs/ofetch/issues/29)) ([e794b1e](https://github.com/unjs/ofetch/commit/e794b1e4644f803e9c18c8813c432b29c7f9ef33)) 505 | 506 | ### [0.4.6](https://github.com/unjs/ofetch/compare/v0.4.5...v0.4.6) (2021-11-10) 507 | 508 | 509 | ### Bug Fixes 510 | 511 | * add check for using `Error.captureStackTrace` ([#27](https://github.com/unjs/ofetch/issues/27)) ([0c55e1e](https://github.com/unjs/ofetch/commit/0c55e1ec1bf21fcb3a7fc6ed570956b4dfba80d5)) 512 | * remove baseurl append on retry ([#25](https://github.com/unjs/ofetch/issues/25)) ([7e1b54d](https://github.com/unjs/ofetch/commit/7e1b54d1363ba3f5fe57fe8089babab921a8e9ea)) 513 | 514 | ### [0.4.5](https://github.com/unjs/ofetch/compare/v0.4.4...v0.4.5) (2021-11-05) 515 | 516 | 517 | ### Bug Fixes 518 | 519 | * improve error handling for non-user errors ([6b965a5](https://github.com/unjs/ofetch/commit/6b965a5206bd3eb64b86d550dbba2932495bf67d)) 520 | 521 | ### [0.4.4](https://github.com/unjs/ofetch/compare/v0.4.3...v0.4.4) (2021-11-04) 522 | 523 | 524 | ### Bug Fixes 525 | 526 | * allow `retry: false` ([ce8e4d3](https://github.com/unjs/ofetch/commit/ce8e4d31332403937bf7db0b45ecd54bb97c319f)) 527 | 528 | ### [0.4.3](https://github.com/unjs/ofetch/compare/v0.4.2...v0.4.3) (2021-11-04) 529 | 530 | 531 | ### Features 532 | 533 | * experimental undici support ([dfa0b55](https://github.com/unjs/ofetch/commit/dfa0b554c72f1fe03bf3dc3cb0f47b7d306edb63)) 534 | * **node:** pick `globalThis.fetch` when available over `node-fetch` ([54b779b](https://github.com/unjs/ofetch/commit/54b779b97a6722542bdfe4b0f6dd9e82e59a7010)) 535 | * **node:** support http agent with `keepAlive` ([#22](https://github.com/unjs/ofetch/issues/22)) ([18a952a](https://github.com/unjs/ofetch/commit/18a952af10eb40823086837f71a921221e49c559)) 536 | * support retry and default to `1` ([ec83366](https://github.com/unjs/ofetch/commit/ec83366ae3a0cbd2b2b093b92b99ef7fbb561ceb)) 537 | 538 | 539 | ### Bug Fixes 540 | 541 | * remove `at raw` from stack ([82351a8](https://github.com/unjs/ofetch/commit/82351a8b6f5fea0b062bff76881bd8b740352ca8)) 542 | 543 | ### [0.4.2](https://github.com/unjs/ofetch/compare/v0.4.1...v0.4.2) (2021-10-22) 544 | 545 | 546 | ### Features 547 | 548 | * **cjs:** provide `fetch` and `$fetch.raw` exports ([529af1c](https://github.com/unjs/ofetch/commit/529af1c22b31b71ffe9bb21a1f13997ae7aac195)) 549 | 550 | ### [0.4.1](https://github.com/unjs/ofetch/compare/v0.4.0...v0.4.1) (2021-10-22) 551 | 552 | 553 | ### Bug Fixes 554 | 555 | * avoid optional chaining for sake of webpack4 ([38a75fe](https://github.com/unjs/ofetch/commit/38a75fe599a2b96d2d5fe12f2e4630ae4f17a102)) 556 | 557 | ## [0.4.0](https://github.com/unjs/ofetch/compare/v0.3.2...v0.4.0) (2021-10-22) 558 | 559 | 560 | ### ⚠ BREAKING CHANGES 561 | 562 | * upgrade to node-fetch 3.x 563 | 564 | ### Features 565 | 566 | * upgrade to node-fetch 3.x ([ec51edf](https://github.com/unjs/ofetch/commit/ec51edf1fd53e4ab4ef99ec3253ea95353abb50e)) 567 | 568 | ### [0.3.2](https://github.com/unjs/ofetch/compare/v0.3.1...v0.3.2) (2021-10-22) 569 | 570 | 571 | ### Features 572 | 573 | * allow for custom response parser with `parseResponse` ([#16](https://github.com/unjs/ofetch/issues/16)) ([463ced6](https://github.com/unjs/ofetch/commit/463ced66c4d12f8d380d0bca2c5ff2febf38af7e)) 574 | 575 | 576 | ### Bug Fixes 577 | 578 | * check for `globalThis` before fallback to shims ([#20](https://github.com/unjs/ofetch/issues/20)) ([b5c0c3b](https://github.com/unjs/ofetch/commit/b5c0c3bb38289a83164570ffa7458c3f47c6d41b)) 579 | 580 | ### [0.3.1](https://github.com/unjs/ofetch/compare/v0.3.0...v0.3.1) (2021-08-26) 581 | 582 | 583 | ### Bug Fixes 584 | 585 | * include typings ([#12](https://github.com/unjs/ofetch/issues/12)) ([2d9a9e9](https://github.com/unjs/ofetch/commit/2d9a9e921ab42756f6420b728bc5f47447d59df3)) 586 | 587 | ## [0.3.0](https://github.com/unjs/ofetch/compare/v0.2.0...v0.3.0) (2021-08-25) 588 | 589 | 590 | ### ⚠ BREAKING CHANGES 591 | 592 | * use export condition to automatically use node-fetch 593 | 594 | ### Features 595 | 596 | * direct export fetch implementation ([65b27dd](https://github.com/unjs/ofetch/commit/65b27ddb863790af8637b9da1c50c8fba14a295d)) 597 | * use export condition to automatically use node-fetch ([b81082b](https://github.com/unjs/ofetch/commit/b81082b6ab1b8e89fa620699c4f9206101230805)) 598 | 599 | ## [0.2.0](https://github.com/unjs/ofetch/compare/v0.1.8...v0.2.0) (2021-04-06) 600 | 601 | 602 | ### ⚠ BREAKING CHANGES 603 | 604 | * don't inline dependencies 605 | 606 | ### Features 607 | 608 | * don't inline dependencies ([cf3578b](https://github.com/unjs/ofetch/commit/cf3578baf265e1044f22c4ba42b227831c6fd183)) 609 | 610 | ### [0.1.8](https://github.com/unjs/ofetch/compare/v0.1.7...v0.1.8) (2021-02-22) 611 | 612 | 613 | ### Bug Fixes 614 | 615 | * **pkg:** add top level node.d.ts ([dcc1358](https://github.com/unjs/ofetch/commit/dcc13582747ba8404dd26b48d3db755b7775b78b)) 616 | 617 | ### [0.1.7](https://github.com/unjs/ofetch/compare/v0.1.6...v0.1.7) (2021-02-19) 618 | 619 | 620 | ### Features 621 | 622 | * support automatic json body for post requests ([#7](https://github.com/unjs/ofetch/issues/7)) ([97d0987](https://github.com/unjs/ofetch/commit/97d0987131e006e72aac6d1d4acb063f3e53953d)) 623 | 624 | ### [0.1.6](https://github.com/unjs/ofetch/compare/v0.1.5...v0.1.6) (2021-01-12) 625 | 626 | ### [0.1.5](https://github.com/unjs/ofetch/compare/v0.1.4...v0.1.5) (2021-01-04) 627 | 628 | 629 | ### Bug Fixes 630 | 631 | * **pkg:** use same export names for incompatible tools ([7fc450a](https://github.com/unjs/ofetch/commit/7fc450ac81596de1dea53380dc9ef3ae8ceb2304)) 632 | 633 | ### [0.1.4](https://github.com/unjs/ofetch/compare/v0.1.3...v0.1.4) (2020-12-16) 634 | 635 | 636 | ### Features 637 | 638 | * update ufo to 0.5 (reducing bundle size) ([837707d](https://github.com/unjs/ofetch/commit/837707d2ed03a7c6e69127849bf0c25ae182982d)) 639 | 640 | ### [0.1.3](https://github.com/unjs/ofetch/compare/v0.1.2...v0.1.3) (2020-12-16) 641 | 642 | 643 | ### Bug Fixes 644 | 645 | * ufo 0.3.1 ([e56e73e](https://github.com/unjs/ofetch/commit/e56e73e90bb6ad9be88f7c8413053744a64c702e)) 646 | 647 | ### [0.1.2](https://github.com/unjs/ofetch/compare/v0.1.1...v0.1.2) (2020-12-16) 648 | 649 | 650 | ### Bug Fixes 651 | 652 | * update ufo to 0.3 ([52d84e7](https://github.com/unjs/ofetch/commit/52d84e75034c3c6fd7542b2829e06f6d87f069c2)) 653 | 654 | ### [0.1.1](https://github.com/unjs/ofetch/compare/v0.0.7...v0.1.1) (2020-12-12) 655 | 656 | 657 | ### Bug Fixes 658 | 659 | * preserve params when using baseURL ([c3a63e2](https://github.com/unjs/ofetch/commit/c3a63e2b337b09b082eb9faf8e23e818d866c49c)) 660 | 661 | ### [0.0.7](https://github.com/unjs/ofetch/compare/v0.0.6...v0.0.7) (2020-12-12) 662 | 663 | ### [0.0.6](https://github.com/unjs/ofetch/compare/v0.0.5...v0.0.6) (2020-12-12) 664 | 665 | 666 | ### Bug Fixes 667 | 668 | * **pkg:** fix top level named exports ([0b51462](https://github.com/unjs/ofetch/commit/0b514620dcfa65d156397114b87ed5e4f28e33a1)) 669 | 670 | ### [0.0.5](https://github.com/unjs/ofetch/compare/v0.0.4...v0.0.5) (2020-12-12) 671 | 672 | 673 | ### Bug Fixes 674 | 675 | * **pkg:** fix ./node in exports ([c6b27b7](https://github.com/unjs/ofetch/commit/c6b27b7cb61d66444f3d43bfa5226057ec7a9c95)) 676 | 677 | ### [0.0.4](https://github.com/unjs/ofetch/compare/v0.0.3...v0.0.4) (2020-12-12) 678 | 679 | 680 | ### Features 681 | 682 | * support params ([e6a56ff](https://github.com/unjs/ofetch/commit/e6a56ff083244fac918e29058aaf28bf87c98384)) 683 | 684 | ### [0.0.3](https://github.com/unjs/ofetch/compare/v0.0.2...v0.0.3) (2020-12-12) 685 | 686 | 687 | ### Features 688 | 689 | * bundle ufo and destr for easier bundler integration ([8f5ba88](https://github.com/unjs/ofetch/commit/8f5ba88f1ac0aa40ff2c99316da98a71d6dcc7e8)) 690 | * support `$fetch.raw` and improve docs ([f9f70a5](https://github.com/unjs/ofetch/commit/f9f70a59222bc0d0166cbe9a03eebf2a73682398)) 691 | 692 | ### [0.0.2](https://github.com/unjs/ofetch/compare/v0.0.1...v0.0.2) (2020-12-09) 693 | 694 | 695 | ### Bug Fixes 696 | 697 | * **pkg:** add top level dist ([6da17ca](https://github.com/unjs/ofetch/commit/6da17cad07e08cff9e5ea9e8b505638d560bcb47)) 698 | 699 | ### 0.0.1 (2020-12-09) 700 | 701 | 702 | ### Features 703 | 704 | * universal + isomorphic builds ([a873702](https://github.com/unjs/ofetch/commit/a873702c336c7ecce87c506d81c146db9f7516d0)) 705 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Pooya Parsa 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ofetch 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![bundle][bundle-src]][bundle-href] 6 | [![Codecov][codecov-src]][codecov-href] 7 | [![License][license-src]][license-href] 8 | [![JSDocs][jsdocs-src]][jsdocs-href] 9 | 10 | A better fetch API. Works on node, browser, and workers. 11 | 12 |
13 | Spoiler 14 | 15 |
16 | 17 | ## 🚀 Quick Start 18 | 19 | Install: 20 | 21 | ```bash 22 | # npm 23 | npm i ofetch 24 | 25 | # yarn 26 | yarn add ofetch 27 | ``` 28 | 29 | Import: 30 | 31 | ```js 32 | // ESM / Typescript 33 | import { ofetch } from "ofetch"; 34 | 35 | // CommonJS 36 | const { ofetch } = require("ofetch"); 37 | ``` 38 | 39 | ## ✔️ Works with Node.js 40 | 41 | We use [conditional exports](https://nodejs.org/api/packages.html#packages_conditional_exports) to detect Node.js 42 | and automatically use [unjs/node-fetch-native](https://github.com/unjs/node-fetch-native). If `globalThis.fetch` is available, will be used instead. To leverage Node.js 17.5.0 experimental native fetch API use [`--experimental-fetch` flag](https://nodejs.org/dist/latest-v17.x/docs/api/cli.html#--experimental-fetch). 43 | 44 | ## ✔️ Parsing Response 45 | 46 | `ofetch` will smartly parse JSON and native values using [destr](https://github.com/unjs/destr), falling back to the text if it fails to parse. 47 | 48 | ```js 49 | const { users } = await ofetch("/api/users"); 50 | ``` 51 | 52 | For binary content types, `ofetch` will instead return a `Blob` object. 53 | 54 | You can optionally provide a different parser than `destr`, or specify `blob`, `arrayBuffer`, `text` or `stream` to force parsing the body with the respective `FetchResponse` method. 55 | 56 | ```js 57 | // Use JSON.parse 58 | await ofetch("/movie?lang=en", { parseResponse: JSON.parse }); 59 | 60 | // Return text as is 61 | await ofetch("/movie?lang=en", { parseResponse: (txt) => txt }); 62 | 63 | // Get the blob version of the response 64 | await ofetch("/api/generate-image", { responseType: "blob" }); 65 | 66 | // Get the stream version of the response 67 | await ofetch("/api/generate-image", { responseType: "stream" }); 68 | ``` 69 | 70 | ## ✔️ JSON Body 71 | 72 | If an object or a class with a `.toJSON()` method is passed to the `body` option, `ofetch` automatically stringifies it. 73 | 74 | `ofetch` utilizes `JSON.stringify()` to convert the passed object. Classes without a `.toJSON()` method have to be converted into a string value in advance before being passed to the `body` option. 75 | 76 | For `PUT`, `PATCH`, and `POST` request methods, when a string or object body is set, `ofetch` adds the default `"content-type": "application/json"` and `accept: "application/json"` headers (which you can always override). 77 | 78 | Additionally, `ofetch` supports binary responses with `Buffer`, `ReadableStream`, `Stream`, and [compatible body types](https://developer.mozilla.org/en-US/docs/Web/API/fetch#body). `ofetch` will automatically set the `duplex: "half"` option for streaming support! 79 | 80 | **Example:** 81 | 82 | ```js 83 | const { users } = await ofetch("/api/users", { 84 | method: "POST", 85 | body: { some: "json" }, 86 | }); 87 | ``` 88 | 89 | ## ✔️ Handling Errors 90 | 91 | `ofetch` Automatically throws errors when `response.ok` is `false` with a friendly error message and compact stack (hiding internals). 92 | 93 | A parsed error body is available with `error.data`. You may also use `FetchError` type. 94 | 95 | ```ts 96 | await ofetch("https://google.com/404"); 97 | // FetchError: [GET] "https://google/404": 404 Not Found 98 | // at async main (/project/playground.ts:4:3) 99 | ``` 100 | 101 | To catch error response: 102 | 103 | ```ts 104 | await ofetch("/url").catch((error) => error.data); 105 | ``` 106 | 107 | To bypass status error catching you can set `ignoreResponseError` option: 108 | 109 | ```ts 110 | await ofetch("/url", { ignoreResponseError: true }); 111 | ``` 112 | 113 | ## ✔️ Auto Retry 114 | 115 | `ofetch` Automatically retries the request if an error happens and if the response status code is included in `retryStatusCodes` list: 116 | 117 | **Retry status codes:** 118 | 119 | - `408` - Request Timeout 120 | - `409` - Conflict 121 | - `425` - Too Early ([Experimental](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Early-Data)) 122 | - `429` - Too Many Requests 123 | - `500` - Internal Server Error 124 | - `502` - Bad Gateway 125 | - `503` - Service Unavailable 126 | - `504` - Gateway Timeout 127 | 128 | You can specify the amount of retry and delay between them using `retry` and `retryDelay` options and also pass a custom array of codes using `retryStatusCodes` option. 129 | 130 | The default for `retry` is `1` retry, except for `POST`, `PUT`, `PATCH`, and `DELETE` methods where `ofetch` does not retry by default to avoid introducing side effects. If you set a custom value for `retry` it will **always retry** for all requests. 131 | 132 | The default for `retryDelay` is `0` ms. 133 | 134 | ```ts 135 | await ofetch("http://google.com/404", { 136 | retry: 3, 137 | retryDelay: 500, // ms 138 | retryStatusCodes: [ 404, 500 ], // response status codes to retry 139 | }); 140 | ``` 141 | 142 | ## ✔️ Timeout 143 | 144 | You can specify `timeout` in milliseconds to automatically abort a request after a timeout (default is disabled). 145 | 146 | ```ts 147 | await ofetch("http://google.com/404", { 148 | timeout: 3000, // Timeout after 3 seconds 149 | }); 150 | ``` 151 | 152 | ## ✔️ Type Friendly 153 | 154 | The response can be type assisted: 155 | 156 | ```ts 157 | const article = await ofetch
(`/api/article/${id}`); 158 | // Auto complete working with article.id 159 | ``` 160 | 161 | ## ✔️ Adding `baseURL` 162 | 163 | By using `baseURL` option, `ofetch` prepends it for trailing/leading slashes and query search params for baseURL using [ufo](https://github.com/unjs/ufo): 164 | 165 | ```js 166 | await ofetch("/config", { baseURL }); 167 | ``` 168 | 169 | ## ✔️ Adding Query Search Params 170 | 171 | By using `query` option (or `params` as alias), `ofetch` adds query search params to the URL by preserving the query in the request itself using [ufo](https://github.com/unjs/ufo): 172 | 173 | ```js 174 | await ofetch("/movie?lang=en", { query: { id: 123 } }); 175 | ``` 176 | 177 | ## ✔️ Interceptors 178 | 179 | Providing async interceptors to hook into lifecycle events of `ofetch` call is possible. 180 | 181 | You might want to use `ofetch.create` to set shared interceptors. 182 | 183 | ### `onRequest({ request, options })` 184 | 185 | `onRequest` is called as soon as `ofetch` is called, allowing you to modify options or do simple logging. 186 | 187 | ```js 188 | await ofetch("/api", { 189 | async onRequest({ request, options }) { 190 | // Log request 191 | console.log("[fetch request]", request, options); 192 | 193 | // Add `?t=1640125211170` to query search params 194 | options.query = options.query || {}; 195 | options.query.t = new Date(); 196 | }, 197 | }); 198 | ``` 199 | 200 | ### `onRequestError({ request, options, error })` 201 | 202 | `onRequestError` will be called when the fetch request fails. 203 | 204 | ```js 205 | await ofetch("/api", { 206 | async onRequestError({ request, options, error }) { 207 | // Log error 208 | console.log("[fetch request error]", request, error); 209 | }, 210 | }); 211 | ``` 212 | 213 | ### `onResponse({ request, options, response })` 214 | 215 | `onResponse` will be called after `fetch` call and parsing body. 216 | 217 | ```js 218 | await ofetch("/api", { 219 | async onResponse({ request, response, options }) { 220 | // Log response 221 | console.log("[fetch response]", request, response.status, response.body); 222 | }, 223 | }); 224 | ``` 225 | 226 | ### `onResponseError({ request, options, response })` 227 | 228 | `onResponseError` is the same as `onResponse` but will be called when fetch happens but `response.ok` is not `true`. 229 | 230 | ```js 231 | await ofetch("/api", { 232 | async onResponseError({ request, response, options }) { 233 | // Log error 234 | console.log( 235 | "[fetch response error]", 236 | request, 237 | response.status, 238 | response.body 239 | ); 240 | }, 241 | }); 242 | ``` 243 | 244 | ### Passing array of interceptors 245 | 246 | If necessary, it's also possible to pass an array of function that will be called sequentially. 247 | 248 | ```js 249 | await ofetch("/api", { 250 | onRequest: [ 251 | () => { 252 | /* Do something */ 253 | }, 254 | () => { 255 | /* Do something else */ 256 | }, 257 | ], 258 | }); 259 | ``` 260 | 261 | ## ✔️ Create fetch with default options 262 | 263 | This utility is useful if you need to use common options across several fetch calls. 264 | 265 | **Note:** Defaults will be cloned at one level and inherited. Be careful about nested options like `headers`. 266 | 267 | ```js 268 | const apiFetch = ofetch.create({ baseURL: "/api" }); 269 | 270 | apiFetch("/test"); // Same as ofetch('/test', { baseURL: '/api' }) 271 | ``` 272 | 273 | ## 💡 Adding headers 274 | 275 | By using `headers` option, `ofetch` adds extra headers in addition to the request default headers: 276 | 277 | ```js 278 | await ofetch("/movies", { 279 | headers: { 280 | Accept: "application/json", 281 | "Cache-Control": "no-cache", 282 | }, 283 | }); 284 | ``` 285 | 286 | ## 🍣 Access to Raw Response 287 | 288 | If you need to access raw response (for headers, etc), you can use `ofetch.raw`: 289 | 290 | ```js 291 | const response = await ofetch.raw("/sushi"); 292 | 293 | // response._data 294 | // response.headers 295 | // ... 296 | ``` 297 | 298 | ## 🌿 Using Native Fetch 299 | 300 | As a shortcut, you can use `ofetch.native` that provides native `fetch` API 301 | 302 | ```js 303 | const json = await ofetch.native("/sushi").then((r) => r.json()); 304 | ``` 305 | 306 | ## 📡 SSE 307 | 308 | **Example:** Handle SSE response: 309 | 310 | ```js 311 | const stream = await ofetch("/sse") 312 | const reader = stream.getReader(); 313 | const decoder = new TextDecoder() 314 | while (true) { 315 | const { done, value } = await reader.read(); 316 | if (done) break; 317 | // Here is the chunked text of the SSE response. 318 | const text = decoder.decode(value) 319 | } 320 | ``` 321 | 322 | ## 🕵️ Adding HTTP(S) Agent 323 | 324 | In Node.js (>= 18) environments, you can provide a custom dispatcher to intercept requests and support features such as Proxy and self-signed certificates. This feature is enabled by [undici](https://undici.nodejs.org/) built-in Node.js. [read more](https://undici.nodejs.org/#/docs/api/Dispatcher) about the Dispatcher API. 325 | 326 | Some available agents: 327 | 328 | - `ProxyAgent`: A Proxy Agent class that implements the Agent API. It allows the connection through a proxy in a simple way. ([docs](https://undici.nodejs.org/#/docs/api/ProxyAgent)) 329 | - `MockAgent`: A mocked Agent class that implements the Agent API. It allows one to intercept HTTP requests made through undici and return mocked responses instead. ([docs](https://undici.nodejs.org/#/docs/api/MockAgent)) 330 | - `Agent`: Agent allows dispatching requests against multiple different origins. ([docs](https://undici.nodejs.org/#/docs/api/Agent)) 331 | 332 | **Example:** Set a proxy agent for one request: 333 | 334 | ```ts 335 | import { ProxyAgent } from "undici"; 336 | import { ofetch } from "ofetch"; 337 | 338 | const proxyAgent = new ProxyAgent("http://localhost:3128"); 339 | const data = await ofetch("https://icanhazip.com", { dispatcher: proxyAgent }); 340 | ``` 341 | 342 | **Example:** Create a custom fetch instance that has proxy enabled: 343 | 344 | ```ts 345 | import { ProxyAgent, setGlobalDispatcher } from "undici"; 346 | import { ofetch } from "ofetch"; 347 | 348 | const proxyAgent = new ProxyAgent("http://localhost:3128"); 349 | const fetchWithProxy = ofetch.create({ dispatcher: proxyAgent }); 350 | 351 | const data = await fetchWithProxy("https://icanhazip.com"); 352 | ``` 353 | 354 | **Example:** Set a proxy agent for all requests: 355 | 356 | ```ts 357 | import { ProxyAgent, setGlobalDispatcher } from "undici"; 358 | import { ofetch } from "ofetch"; 359 | 360 | const proxyAgent = new ProxyAgent("http://localhost:3128"); 361 | setGlobalDispatcher(proxyAgent); 362 | 363 | const data = await ofetch("https://icanhazip.com"); 364 | ``` 365 | 366 | **Example:** Allow self-signed certificates (USE AT YOUR OWN RISK!) 367 | 368 | ```ts 369 | import { Agent } from "undici"; 370 | import { ofetch } from "ofetch"; 371 | 372 | // Note: This makes fetch unsecure against MITM attacks. USE AT YOUR OWN RISK! 373 | const unsecureAgent = new Agent({ connect: { rejectUnauthorized: false } }); 374 | const unsecureFetch = ofetch.create({ dispatcher: unsecureAgent }); 375 | 376 | const data = await unsecureFetch("https://www.squid-cache.org/"); 377 | ``` 378 | 379 | On older Node.js version (<18), you might also use use `agent`: 380 | 381 | ```ts 382 | import { HttpsProxyAgent } from "https-proxy-agent"; 383 | 384 | await ofetch("/api", { 385 | agent: new HttpsProxyAgent("http://example.com"), 386 | }); 387 | ``` 388 | 389 | ### `keepAlive` support (only works for Node < 18) 390 | 391 | By setting the `FETCH_KEEP_ALIVE` environment variable to `true`, an HTTP/HTTPS agent will be registered that keeps sockets around even when there are no outstanding requests, so they can be used for future requests without having to re-establish a TCP connection. 392 | 393 | **Note:** This option can potentially introduce memory leaks. Please check [node-fetch/node-fetch#1325](https://github.com/node-fetch/node-fetch/pull/1325). 394 | 395 | ### 💪 Augment `FetchOptions` interface 396 | 397 | You can augment the `FetchOptions` interface to add custom properties. 398 | 399 | ```ts 400 | // Place this in any `.ts` or `.d.ts` file. 401 | // Ensure it's included in the project's tsconfig.json "files". 402 | declare module "ofetch" { 403 | interface FetchOptions { 404 | // Custom properties 405 | requiresAuth?: boolean; 406 | } 407 | } 408 | 409 | export {} 410 | ``` 411 | 412 | This lets you pass and use those properties with full type safety throughout `ofetch` calls. 413 | 414 | ```ts 415 | const myFetch = ofetch.create({ 416 | onRequest(context) { 417 | // ^? { ..., options: {..., requiresAuth?: boolean }} 418 | console.log(context.options.requiresAuth); 419 | }, 420 | }); 421 | 422 | myFetch("/foo", { requiresAuth: true }) 423 | ``` 424 | 425 | ## 📦 Bundler Notes 426 | 427 | - All targets are exported with Module and CommonJS format and named exports 428 | - No export is transpiled for the sake of modern syntax 429 | - You probably need to transpile `ofetch`, `destr`, and `ufo` packages with Babel for ES5 support 430 | - You need to polyfill `fetch` global for supporting legacy browsers like using [unfetch](https://github.com/developit/unfetch) 431 | 432 | ## ❓ FAQ 433 | 434 | **Why export is called `ofetch` instead of `fetch`?** 435 | 436 | Using the same name of `fetch` can be confusing since API is different but still, it is a fetch so using the closest possible alternative. You can, however, import `{ fetch }` from `ofetch` which is auto-polyfill for Node.js and using native otherwise. 437 | 438 | **Why not have default export?** 439 | 440 | Default exports are always risky to be mixed with CommonJS exports. 441 | 442 | This also guarantees we can introduce more utils without breaking the package and also encourage using `ofetch` name. 443 | 444 | **Why not transpiled?** 445 | 446 | By transpiling libraries, we push the web backward with legacy code which is unneeded for most of the users. 447 | 448 | If you need to support legacy users, you can optionally transpile the library in your build pipeline. 449 | 450 | ## License 451 | 452 | MIT. Made with 💖 453 | 454 | 455 | 456 | [npm-version-src]: https://img.shields.io/npm/v/ofetch?style=flat&colorA=18181B&colorB=F0DB4F 457 | [npm-version-href]: https://npmjs.com/package/ofetch 458 | [npm-downloads-src]: https://img.shields.io/npm/dm/ofetch?style=flat&colorA=18181B&colorB=F0DB4F 459 | [npm-downloads-href]: https://npmjs.com/package/ofetch 460 | [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/ofetch/main?style=flat&colorA=18181B&colorB=F0DB4F 461 | [codecov-href]: https://codecov.io/gh/unjs/ofetch 462 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/ofetch?style=flat&colorA=18181B&colorB=F0DB4F 463 | [bundle-href]: https://bundlephobia.com/result?p=ofetch 464 | [license-src]: https://img.shields.io/github/license/unjs/ofetch.svg?style=flat&colorA=18181B&colorB=F0DB4F 465 | [license-href]: https://github.com/unjs/ofetch/blob/main/LICENSE 466 | [jsdocs-src]: https://img.shields.io/badge/jsDocs.io-reference-18181B?style=flat&colorA=18181B&colorB=F0DB4F 467 | [jsdocs-href]: https://www.jsdocs.io/package/ofetch 468 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from "unbuild"; 2 | 3 | export default defineBuildConfig({ 4 | declaration: true, 5 | rollup: { 6 | emitCJS: true, 7 | }, 8 | entries: ["src/index", "src/node"], 9 | externals: ["undici"], 10 | }); 11 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | // https://github.com/unjs/eslint-config 4 | export default unjs({ 5 | ignores: [], 6 | rules: { 7 | "no-undef": 0, 8 | "unicorn/consistent-destructuring": 0, 9 | "unicorn/no-await-expression-member": 0, 10 | "@typescript-eslint/no-empty-object-type": 0, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # ofetch examples 2 | 3 | In this directory you can find some examples of how to use ofetch. 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/body.mjs: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | const response = await ofetch("https://api.github.com/markdown", { 4 | method: "POST", 5 | // To provide a body, we need to use the `body` option and just use an object. 6 | body: { 7 | text: "UnJS is **awesome**!\n\nCheck out their [website](https://unjs.io).", 8 | }, 9 | }); 10 | 11 | console.log(response); 12 | -------------------------------------------------------------------------------- /examples/error-handling.mjs: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | try { 4 | await ofetch("https://api.github.com", { 5 | method: "POST", 6 | }); 7 | } catch (error) { 8 | // Error will be pretty printed 9 | console.error(error); 10 | 11 | // This allows us to access the error body 12 | console.log(error.data); 13 | } 14 | -------------------------------------------------------------------------------- /examples/first-request.mjs: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | const data = await ofetch("https://ungh.cc/repos/unjs/ofetch"); 4 | 5 | console.log(data); 6 | -------------------------------------------------------------------------------- /examples/headers.mjs: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | const response = await ofetch("https://api.github.com/gists", { 4 | method: "POST", 5 | headers: { 6 | Authorization: `token ${process.env.GH_TOKEN}`, 7 | }, 8 | body: { 9 | description: "This is a gist created by ofetch.", 10 | public: true, 11 | files: { 12 | "unjs.txt": { 13 | content: "UnJS is awesome!", 14 | }, 15 | }, 16 | }, 17 | }); // Be careful, we use the GitHub API directly. 18 | 19 | console.log(response.url); 20 | -------------------------------------------------------------------------------- /examples/methods.mjs: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | const response = await ofetch("https://api.github.com/gists", { 4 | method: "POST", 5 | }); // Be careful, we use the GitHub API directly. 6 | 7 | console.log(response); 8 | -------------------------------------------------------------------------------- /examples/proxy.mjs: -------------------------------------------------------------------------------- 1 | import { Agent } from "undici"; 2 | import { ofetch } from "ofetch"; 3 | 4 | // Note: This makes fetch unsecure to MITM attacks. USE AT YOUR OWN RISK! 5 | const unsecureAgent = new Agent({ connect: { rejectUnauthorized: false } }); 6 | const unsecureFetch = ofetch.create({ dispatcher: unsecureAgent }); 7 | const data = await unsecureFetch("https://www.squid-cache.org/"); 8 | 9 | console.log(data); 10 | -------------------------------------------------------------------------------- /examples/query-string.mjs: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | const response = await ofetch("https://api.github.com/repos/unjs/ofetch/tags", { 4 | query: { 5 | per_page: 2, 6 | }, 7 | }); // Be careful, we use the GitHub API directly. 8 | 9 | console.log(response); 10 | -------------------------------------------------------------------------------- /examples/type-safety.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { ofetch } from "ofetch"; 3 | 4 | interface Repo { 5 | id: number; 6 | name: string; 7 | repo: string; 8 | description: string; 9 | stars: number; 10 | } 11 | 12 | async function main() { 13 | const { repo } = await ofetch<{ repo: Repo }>( 14 | "https://ungh.cc/repos/unjs/ofetch" 15 | ); 16 | 17 | console.log(`The repo ${repo.name} has ${repo.stars} stars.`); // The repo object is now strongly typed. 18 | } 19 | 20 | // eslint-disable-next-line unicorn/prefer-top-level-await 21 | main().catch(console.error); 22 | -------------------------------------------------------------------------------- /node.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./dist/node"; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ofetch", 3 | "version": "1.4.1", 4 | "description": "A better fetch API. Works on node, browser and workers.", 5 | "repository": "unjs/ofetch", 6 | "license": "MIT", 7 | "sideEffects": false, 8 | "type": "module", 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": { 12 | "browser": "./dist/index.mjs", 13 | "bun": "./dist/index.mjs", 14 | "deno": "./dist/index.mjs", 15 | "edge-light": "./dist/index.mjs", 16 | "edge-routine": "./dist/index.mjs", 17 | "netlify": "./dist/index.mjs", 18 | "react-native": "./dist/index.mjs", 19 | "wintercg": "./dist/index.mjs", 20 | "worker": "./dist/index.mjs", 21 | "workerd": "./dist/index.mjs", 22 | "node": { 23 | "import": { 24 | "types": "./dist/node.d.mts", 25 | "default": "./dist/node.mjs" 26 | }, 27 | "require": { 28 | "types": "./dist/node.d.cts", 29 | "default": "./dist/node.cjs" 30 | } 31 | }, 32 | "import": { 33 | "types": "./dist/index.d.mts", 34 | "default": "./dist/index.mjs" 35 | }, 36 | "require": { 37 | "types": "./dist/node.d.cts", 38 | "default": "./dist/node.cjs" 39 | }, 40 | "types": "./dist/index.d.mts", 41 | "default": "./dist/index.mjs" 42 | }, 43 | "./node": { 44 | "import": { 45 | "types": "./dist/node.d.mts", 46 | "default": "./dist/node.mjs" 47 | }, 48 | "require": { 49 | "types": "./dist/node.d.cts", 50 | "default": "./dist/node.cjs" 51 | } 52 | } 53 | }, 54 | "main": "./dist/node.cjs", 55 | "module": "./dist/index.mjs", 56 | "react-native": "./dist/index.mjs", 57 | "types": "./dist/index.d.ts", 58 | "files": [ 59 | "dist", 60 | "node.d.ts" 61 | ], 62 | "scripts": { 63 | "build": "unbuild", 64 | "dev": "vitest", 65 | "lint": "eslint . && prettier -c src test playground examples", 66 | "lint:fix": "eslint --fix . && prettier -w src test playground examples", 67 | "prepack": "pnpm build", 68 | "play": "jiti playground/index.ts", 69 | "release": "pnpm test && changelogen --release && npm publish && git push --follow-tags", 70 | "test": "pnpm lint && vitest run --coverage" 71 | }, 72 | "dependencies": { 73 | "destr": "^2.0.5", 74 | "node-fetch-native": "^1.6.7", 75 | "ufo": "^1.6.1" 76 | }, 77 | "devDependencies": { 78 | "@types/node": "^24.3.0", 79 | "@vitest/coverage-v8": "^3.2.4", 80 | "changelogen": "^0.6.2", 81 | "eslint": "^9.34.0", 82 | "eslint-config-unjs": "^0.5.0", 83 | "fetch-blob": "^4.0.0", 84 | "formdata-polyfill": "^4.0.10", 85 | "h3": "^1.15.4", 86 | "jiti": "^2.5.1", 87 | "listhen": "^1.9.0", 88 | "prettier": "^3.6.2", 89 | "std-env": "^3.9.0", 90 | "typescript": "^5.9.2", 91 | "unbuild": "^3.6.1", 92 | "undici": "^7.15.0", 93 | "vitest": "^3.2.4" 94 | }, 95 | "packageManager": "pnpm@10.15.0" 96 | } 97 | -------------------------------------------------------------------------------- /playground/index.mjs: -------------------------------------------------------------------------------- 1 | import { $fetch } from ".."; 2 | 3 | async function main() { 4 | await $fetch("http://google.com/404"); 5 | } 6 | 7 | // eslint-disable-next-line unicorn/prefer-top-level-await 8 | main().catch((error) => { 9 | console.error(error); 10 | // eslint-disable-next-line unicorn/no-process-exit 11 | process.exit(1); 12 | }); 13 | -------------------------------------------------------------------------------- /playground/index.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from "../src/node"; 2 | 3 | async function main() { 4 | // const r = await $fetch('http://google.com/404') 5 | const r = await $fetch("http://httpstat.us/500"); 6 | // const r = await $fetch('http://httpstat/500') 7 | 8 | console.log(r); 9 | } 10 | 11 | // eslint-disable-next-line unicorn/prefer-top-level-await 12 | main().catch((error) => { 13 | console.error(error); 14 | // eslint-disable-next-line unicorn/no-process-exit 15 | process.exit(1); 16 | }); 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | export * from "./fetch"; 2 | export * from "./error"; 3 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import type { FetchContext, IFetchError } from "./types"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging 4 | export class FetchError extends Error implements IFetchError { 5 | constructor(message: string, opts?: { cause: unknown }) { 6 | // @ts-ignore https://v8.dev/features/error-cause 7 | super(message, opts); 8 | 9 | this.name = "FetchError"; 10 | 11 | // Polyfill cause for other runtimes 12 | if (opts?.cause && !this.cause) { 13 | this.cause = opts.cause; 14 | } 15 | } 16 | } 17 | 18 | // Augment `FetchError` type to include `IFetchError` properties 19 | export interface FetchError extends IFetchError {} 20 | 21 | export function createFetchError( 22 | ctx: FetchContext 23 | ): IFetchError { 24 | const errorMessage = ctx.error?.message || ctx.error?.toString() || ""; 25 | 26 | const method = 27 | (ctx.request as Request)?.method || ctx.options?.method || "GET"; 28 | const url = (ctx.request as Request)?.url || String(ctx.request) || "/"; 29 | const requestStr = `[${method}] ${JSON.stringify(url)}`; 30 | 31 | const statusStr = ctx.response 32 | ? `${ctx.response.status} ${ctx.response.statusText}` 33 | : ""; 34 | 35 | const message = `${requestStr}: ${statusStr}${ 36 | errorMessage ? ` ${errorMessage}` : "" 37 | }`; 38 | 39 | const fetchError: FetchError = new FetchError( 40 | message, 41 | ctx.error ? { cause: ctx.error } : undefined 42 | ); 43 | 44 | for (const key of ["request", "options", "response"] as const) { 45 | Object.defineProperty(fetchError, key, { 46 | get() { 47 | return ctx[key]; 48 | }, 49 | }); 50 | } 51 | 52 | for (const [key, refKey] of [ 53 | ["data", "_data"], 54 | ["status", "status"], 55 | ["statusCode", "status"], 56 | ["statusText", "statusText"], 57 | ["statusMessage", "statusText"], 58 | ] as const) { 59 | Object.defineProperty(fetchError, key, { 60 | get() { 61 | return ctx.response && ctx.response[refKey]; 62 | }, 63 | }); 64 | } 65 | 66 | return fetchError; 67 | } 68 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | import type { Readable } from "node:stream"; 2 | import destr from "destr"; 3 | import { withBase, withQuery } from "ufo"; 4 | import { createFetchError } from "./error"; 5 | import { 6 | isPayloadMethod, 7 | isJSONSerializable, 8 | detectResponseType, 9 | resolveFetchOptions, 10 | callHooks, 11 | } from "./utils"; 12 | import type { 13 | CreateFetchOptions, 14 | FetchResponse, 15 | ResponseType, 16 | FetchContext, 17 | $Fetch, 18 | FetchRequest, 19 | FetchOptions, 20 | } from "./types"; 21 | 22 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status 23 | const retryStatusCodes = new Set([ 24 | 408, // Request Timeout 25 | 409, // Conflict 26 | 425, // Too Early (Experimental) 27 | 429, // Too Many Requests 28 | 500, // Internal Server Error 29 | 502, // Bad Gateway 30 | 503, // Service Unavailable 31 | 504, // Gateway Timeout 32 | ]); 33 | 34 | // https://developer.mozilla.org/en-US/docs/Web/API/Response/body 35 | const nullBodyResponses = new Set([101, 204, 205, 304]); 36 | 37 | export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { 38 | const { 39 | fetch = globalThis.fetch, 40 | Headers = globalThis.Headers, 41 | AbortController = globalThis.AbortController, 42 | } = globalOptions; 43 | 44 | async function onError(context: FetchContext): Promise> { 45 | // Is Abort 46 | // If it is an active abort, it will not retry automatically. 47 | // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#error_names 48 | const isAbort = 49 | (context.error && 50 | context.error.name === "AbortError" && 51 | !context.options.timeout) || 52 | false; 53 | // Retry 54 | if (context.options.retry !== false && !isAbort) { 55 | let retries; 56 | if (typeof context.options.retry === "number") { 57 | retries = context.options.retry; 58 | } else { 59 | retries = isPayloadMethod(context.options.method) ? 0 : 1; 60 | } 61 | 62 | const responseCode = (context.response && context.response.status) || 500; 63 | if ( 64 | retries > 0 && 65 | (Array.isArray(context.options.retryStatusCodes) 66 | ? context.options.retryStatusCodes.includes(responseCode) 67 | : retryStatusCodes.has(responseCode)) 68 | ) { 69 | const retryDelay = 70 | typeof context.options.retryDelay === "function" 71 | ? context.options.retryDelay(context) 72 | : context.options.retryDelay || 0; 73 | if (retryDelay > 0) { 74 | await new Promise((resolve) => setTimeout(resolve, retryDelay)); 75 | } 76 | // Timeout 77 | return $fetchRaw(context.request, { 78 | ...context.options, 79 | retry: retries - 1, 80 | }); 81 | } 82 | } 83 | 84 | // Throw normalized error 85 | const error = createFetchError(context); 86 | 87 | // Only available on V8 based runtimes (https://v8.dev/docs/stack-trace-api) 88 | if (Error.captureStackTrace) { 89 | Error.captureStackTrace(error, $fetchRaw); 90 | } 91 | throw error; 92 | } 93 | 94 | const $fetchRaw: $Fetch["raw"] = async function $fetchRaw< 95 | T = any, 96 | R extends ResponseType = "json", 97 | >(_request: FetchRequest, _options: FetchOptions = {}) { 98 | const context: FetchContext = { 99 | request: _request, 100 | options: resolveFetchOptions( 101 | _request, 102 | _options, 103 | globalOptions.defaults as unknown as FetchOptions, 104 | Headers 105 | ), 106 | response: undefined, 107 | error: undefined, 108 | }; 109 | 110 | // Uppercase method name 111 | if (context.options.method) { 112 | context.options.method = context.options.method.toUpperCase(); 113 | } 114 | 115 | if (context.options.onRequest) { 116 | await callHooks(context, context.options.onRequest); 117 | } 118 | 119 | if (typeof context.request === "string") { 120 | if (context.options.baseURL) { 121 | context.request = withBase(context.request, context.options.baseURL); 122 | } 123 | if (context.options.query) { 124 | context.request = withQuery(context.request, context.options.query); 125 | delete context.options.query; 126 | } 127 | if ("query" in context.options) { 128 | delete context.options.query; 129 | } 130 | if ("params" in context.options) { 131 | delete context.options.params; 132 | } 133 | } 134 | 135 | if (context.options.body && isPayloadMethod(context.options.method)) { 136 | if (isJSONSerializable(context.options.body)) { 137 | const contentType = context.options.headers.get("content-type"); 138 | 139 | // Automatically stringify request bodies, when not already a string. 140 | if (typeof context.options.body !== "string") { 141 | context.options.body = 142 | contentType === "application/x-www-form-urlencoded" 143 | ? new URLSearchParams( 144 | context.options.body as Record 145 | ).toString() 146 | : JSON.stringify(context.options.body); 147 | } 148 | 149 | // Set Content-Type and Accept headers to application/json by default 150 | // for JSON serializable request bodies. 151 | // Pass empty object as older browsers don't support undefined. 152 | context.options.headers = new Headers(context.options.headers || {}); 153 | if (!contentType) { 154 | context.options.headers.set("content-type", "application/json"); 155 | } 156 | if (!context.options.headers.has("accept")) { 157 | context.options.headers.set("accept", "application/json"); 158 | } 159 | } else if ( 160 | // ReadableStream Body 161 | ("pipeTo" in (context.options.body as ReadableStream) && 162 | typeof (context.options.body as ReadableStream).pipeTo === 163 | "function") || 164 | // Node.js Stream Body 165 | typeof (context.options.body as Readable).pipe === "function" 166 | ) { 167 | // eslint-disable-next-line unicorn/no-lonely-if 168 | if (!("duplex" in context.options)) { 169 | context.options.duplex = "half"; 170 | } 171 | } 172 | } 173 | 174 | let abortTimeout: NodeJS.Timeout | undefined; 175 | 176 | // TODO: Can we merge signals? 177 | if (!context.options.signal && context.options.timeout) { 178 | const controller = new AbortController(); 179 | abortTimeout = setTimeout(() => { 180 | const error = new Error( 181 | "[TimeoutError]: The operation was aborted due to timeout" 182 | ); 183 | error.name = "TimeoutError"; 184 | (error as any).code = 23; // DOMException.TIMEOUT_ERR 185 | controller.abort(error); 186 | }, context.options.timeout); 187 | context.options.signal = controller.signal; 188 | } 189 | 190 | try { 191 | context.response = await fetch( 192 | context.request, 193 | context.options as RequestInit 194 | ); 195 | } catch (error) { 196 | context.error = error as Error; 197 | if (context.options.onRequestError) { 198 | await callHooks( 199 | context as FetchContext & { error: Error }, 200 | context.options.onRequestError 201 | ); 202 | } 203 | return await onError(context); 204 | } finally { 205 | if (abortTimeout) { 206 | clearTimeout(abortTimeout); 207 | } 208 | } 209 | 210 | const hasBody = 211 | (context.response.body || 212 | // https://github.com/unjs/ofetch/issues/324 213 | // https://github.com/unjs/ofetch/issues/294 214 | // https://github.com/JakeChampion/fetch/issues/1454 215 | (context.response as any)._bodyInit) && 216 | !nullBodyResponses.has(context.response.status) && 217 | context.options.method !== "HEAD"; 218 | if (hasBody) { 219 | const responseType = 220 | (context.options.parseResponse 221 | ? "json" 222 | : context.options.responseType) || 223 | detectResponseType(context.response.headers.get("content-type") || ""); 224 | 225 | // We override the `.json()` method to parse the body more securely with `destr` 226 | switch (responseType) { 227 | case "json": { 228 | const data = await context.response.text(); 229 | const parseFunction = context.options.parseResponse || destr; 230 | context.response._data = parseFunction(data); 231 | break; 232 | } 233 | case "stream": { 234 | context.response._data = 235 | context.response.body || (context.response as any)._bodyInit; // (see refs above) 236 | break; 237 | } 238 | default: { 239 | context.response._data = await context.response[responseType](); 240 | } 241 | } 242 | } 243 | 244 | if (context.options.onResponse) { 245 | await callHooks( 246 | context as FetchContext & { response: FetchResponse }, 247 | context.options.onResponse 248 | ); 249 | } 250 | 251 | if ( 252 | !context.options.ignoreResponseError && 253 | context.response.status >= 400 && 254 | context.response.status < 600 255 | ) { 256 | if (context.options.onResponseError) { 257 | await callHooks( 258 | context as FetchContext & { response: FetchResponse }, 259 | context.options.onResponseError 260 | ); 261 | } 262 | return await onError(context); 263 | } 264 | 265 | return context.response; 266 | }; 267 | 268 | const $fetch = async function $fetch(request, options) { 269 | const r = await $fetchRaw(request, options); 270 | return r._data; 271 | } as $Fetch; 272 | 273 | $fetch.raw = $fetchRaw; 274 | 275 | $fetch.native = (...args) => fetch(...args); 276 | 277 | $fetch.create = (defaultOptions = {}, customGlobalOptions = {}) => 278 | createFetch({ 279 | ...globalOptions, 280 | ...customGlobalOptions, 281 | defaults: { 282 | ...globalOptions.defaults, 283 | ...customGlobalOptions.defaults, 284 | ...defaultOptions, 285 | }, 286 | }); 287 | 288 | return $fetch; 289 | } 290 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createFetch } from "./base"; 2 | 3 | export * from "./base"; 4 | 5 | export type * from "./types"; 6 | 7 | // ref: https://github.com/tc39/proposal-global 8 | const _globalThis = (function () { 9 | if (typeof globalThis !== "undefined") { 10 | return globalThis; 11 | } 12 | /* eslint-disable unicorn/prefer-global-this */ 13 | if (typeof self !== "undefined") { 14 | return self; 15 | } 16 | if (typeof window !== "undefined") { 17 | return window; 18 | } 19 | if (typeof global !== "undefined") { 20 | return global; 21 | } 22 | /* eslint-enable unicorn/prefer-global-this */ 23 | throw new Error("unable to locate global object"); 24 | })(); 25 | 26 | // ref: https://github.com/unjs/ofetch/issues/295 27 | export const fetch = _globalThis.fetch 28 | ? (...args: Parameters) => _globalThis.fetch(...args) 29 | : () => Promise.reject(new Error("[ofetch] global.fetch is not supported!")); 30 | 31 | export const Headers = _globalThis.Headers; 32 | export const AbortController = _globalThis.AbortController; 33 | 34 | export const ofetch = createFetch({ fetch, Headers, AbortController }); 35 | export const $fetch = ofetch; 36 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import https, { AgentOptions } from "node:https"; 3 | import nodeFetch, { 4 | Headers as _Headers, 5 | AbortController as _AbortController, 6 | } from "node-fetch-native"; 7 | 8 | import { createFetch } from "./base"; 9 | 10 | export * from "./base"; 11 | export type * from "./types"; 12 | 13 | export function createNodeFetch() { 14 | const useKeepAlive = JSON.parse(process.env.FETCH_KEEP_ALIVE || "false"); 15 | if (!useKeepAlive) { 16 | return nodeFetch; 17 | } 18 | 19 | // https://github.com/node-fetch/node-fetch#custom-agent 20 | const agentOptions: AgentOptions = { keepAlive: true }; 21 | const httpAgent = new http.Agent(agentOptions); 22 | const httpsAgent = new https.Agent(agentOptions); 23 | const nodeFetchOptions = { 24 | agent(parsedURL: any) { 25 | return parsedURL.protocol === "http:" ? httpAgent : httpsAgent; 26 | }, 27 | }; 28 | 29 | return function nodeFetchWithKeepAlive( 30 | input: RequestInfo, 31 | init?: RequestInit 32 | ) { 33 | return (nodeFetch as any)(input, { ...nodeFetchOptions, ...init }); 34 | }; 35 | } 36 | 37 | export const fetch = globalThis.fetch 38 | ? (...args: Parameters) => globalThis.fetch(...args) 39 | : (createNodeFetch() as typeof globalThis.fetch); 40 | 41 | export const Headers = globalThis.Headers || _Headers; 42 | export const AbortController = globalThis.AbortController || _AbortController; 43 | 44 | export const ofetch = createFetch({ fetch, Headers, AbortController }); 45 | export const $fetch = ofetch; 46 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // -------------------------- 2 | // $fetch API 3 | // -------------------------- 4 | 5 | export interface $Fetch { 6 | ( 7 | request: FetchRequest, 8 | options?: FetchOptions 9 | ): Promise>; 10 | raw( 11 | request: FetchRequest, 12 | options?: FetchOptions 13 | ): Promise>>; 14 | native: Fetch; 15 | create(defaults: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch; 16 | } 17 | 18 | // -------------------------- 19 | // Options 20 | // -------------------------- 21 | 22 | export interface FetchOptions 23 | extends Omit, 24 | FetchHooks { 25 | baseURL?: string; 26 | 27 | body?: RequestInit["body"] | Record; 28 | 29 | ignoreResponseError?: boolean; 30 | 31 | /** 32 | * @deprecated use query instead. 33 | */ 34 | params?: Record; 35 | 36 | query?: Record; 37 | 38 | parseResponse?: (responseText: string) => any; 39 | 40 | responseType?: R; 41 | 42 | /** 43 | * @experimental Set to "half" to enable duplex streaming. 44 | * Will be automatically set to "half" when using a ReadableStream as body. 45 | * @see https://fetch.spec.whatwg.org/#enumdef-requestduplex 46 | */ 47 | duplex?: "half" | undefined; 48 | 49 | /** 50 | * Only supported in Node.js >= 18 using undici 51 | * 52 | * @see https://undici.nodejs.org/#/docs/api/Dispatcher 53 | */ 54 | dispatcher?: InstanceType; 55 | 56 | /** 57 | * Only supported older Node.js versions using node-fetch-native polyfill. 58 | */ 59 | agent?: unknown; 60 | 61 | /** timeout in milliseconds */ 62 | timeout?: number; 63 | 64 | retry?: number | false; 65 | 66 | /** Delay between retries in milliseconds. */ 67 | retryDelay?: number | ((context: FetchContext) => number); 68 | 69 | /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ 70 | retryStatusCodes?: number[]; 71 | } 72 | 73 | export interface ResolvedFetchOptions< 74 | R extends ResponseType = ResponseType, 75 | T = any, 76 | > extends FetchOptions { 77 | headers: Headers; 78 | } 79 | 80 | export interface CreateFetchOptions { 81 | defaults?: FetchOptions; 82 | fetch?: Fetch; 83 | Headers?: typeof Headers; 84 | AbortController?: typeof AbortController; 85 | } 86 | 87 | export type GlobalOptions = Pick< 88 | FetchOptions, 89 | "timeout" | "retry" | "retryDelay" 90 | >; 91 | 92 | // -------------------------- 93 | // Hooks and Context 94 | // -------------------------- 95 | 96 | export interface FetchContext { 97 | request: FetchRequest; 98 | options: ResolvedFetchOptions; 99 | response?: FetchResponse; 100 | error?: Error; 101 | } 102 | 103 | type MaybePromise = T | Promise; 104 | type MaybeArray = T | T[]; 105 | 106 | export type FetchHook = ( 107 | context: C 108 | ) => MaybePromise; 109 | 110 | export interface FetchHooks { 111 | onRequest?: MaybeArray>>; 112 | onRequestError?: MaybeArray & { error: Error }>>; 113 | onResponse?: MaybeArray< 114 | FetchHook & { response: FetchResponse }> 115 | >; 116 | onResponseError?: MaybeArray< 117 | FetchHook & { response: FetchResponse }> 118 | >; 119 | } 120 | 121 | // -------------------------- 122 | // Response Types 123 | // -------------------------- 124 | 125 | export interface ResponseMap { 126 | blob: Blob; 127 | text: string; 128 | arrayBuffer: ArrayBuffer; 129 | stream: ReadableStream; 130 | } 131 | 132 | export type ResponseType = keyof ResponseMap | "json"; 133 | 134 | export type MappedResponseType< 135 | R extends ResponseType, 136 | JsonType = any, 137 | > = R extends keyof ResponseMap ? ResponseMap[R] : JsonType; 138 | 139 | export interface FetchResponse extends Response { 140 | _data?: T; 141 | } 142 | 143 | // -------------------------- 144 | // Error 145 | // -------------------------- 146 | 147 | export interface IFetchError extends Error { 148 | request?: FetchRequest; 149 | options?: FetchOptions; 150 | response?: FetchResponse; 151 | data?: T; 152 | status?: number; 153 | statusText?: string; 154 | statusCode?: number; 155 | statusMessage?: string; 156 | } 157 | 158 | // -------------------------- 159 | // Other types 160 | // -------------------------- 161 | 162 | export type Fetch = typeof globalThis.fetch; 163 | 164 | export type FetchRequest = RequestInfo; 165 | 166 | export interface SearchParameters { 167 | [key: string]: any; 168 | } 169 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FetchContext, 3 | FetchHook, 4 | FetchOptions, 5 | FetchRequest, 6 | ResolvedFetchOptions, 7 | ResponseType, 8 | } from "./types"; 9 | 10 | const payloadMethods = new Set( 11 | Object.freeze(["PATCH", "POST", "PUT", "DELETE"]) 12 | ); 13 | export function isPayloadMethod(method = "GET") { 14 | return payloadMethods.has(method.toUpperCase()); 15 | } 16 | 17 | export function isJSONSerializable(value: any) { 18 | if (value === undefined) { 19 | return false; 20 | } 21 | const t = typeof value; 22 | if (t === "string" || t === "number" || t === "boolean" || t === null) { 23 | return true; 24 | } 25 | if (t !== "object") { 26 | return false; // bigint, function, symbol, undefined 27 | } 28 | if (Array.isArray(value)) { 29 | return true; 30 | } 31 | if (value.buffer) { 32 | return false; 33 | } 34 | // `FormData` and `URLSearchParams` should't have a `toJSON` method, 35 | // but Bun adds it, which is non-standard. 36 | if (value instanceof FormData || value instanceof URLSearchParams) { 37 | return false; 38 | } 39 | return ( 40 | (value.constructor && value.constructor.name === "Object") || 41 | typeof value.toJSON === "function" 42 | ); 43 | } 44 | 45 | const textTypes = new Set([ 46 | "image/svg", 47 | "application/xml", 48 | "application/xhtml", 49 | "application/html", 50 | ]); 51 | 52 | const JSON_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i; 53 | 54 | // This provides reasonable defaults for the correct parser based on Content-Type header. 55 | export function detectResponseType(_contentType = ""): ResponseType { 56 | if (!_contentType) { 57 | return "json"; 58 | } 59 | 60 | // Value might look like: `application/json; charset=utf-8` 61 | const contentType = _contentType.split(";").shift() || ""; 62 | 63 | if (JSON_RE.test(contentType)) { 64 | return "json"; 65 | } 66 | 67 | // TODO 68 | // if (contentType === 'application/octet-stream') { 69 | // return 'stream' 70 | // } 71 | 72 | // SSE 73 | // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#sending_events_from_the_server 74 | if (contentType === "text/event-stream") { 75 | return "stream"; 76 | } 77 | 78 | if (textTypes.has(contentType) || contentType.startsWith("text/")) { 79 | return "text"; 80 | } 81 | 82 | return "blob"; 83 | } 84 | 85 | export function resolveFetchOptions< 86 | R extends ResponseType = ResponseType, 87 | T = any, 88 | >( 89 | request: FetchRequest, 90 | input: FetchOptions | undefined, 91 | defaults: FetchOptions | undefined, 92 | Headers: typeof globalThis.Headers 93 | ): ResolvedFetchOptions { 94 | // Merge headers 95 | const headers = mergeHeaders( 96 | input?.headers ?? (request as Request)?.headers, 97 | defaults?.headers, 98 | Headers 99 | ); 100 | 101 | // Merge query/params 102 | let query: Record | undefined; 103 | if (defaults?.query || defaults?.params || input?.params || input?.query) { 104 | query = { 105 | ...defaults?.params, 106 | ...defaults?.query, 107 | ...input?.params, 108 | ...input?.query, 109 | }; 110 | } 111 | 112 | return { 113 | ...defaults, 114 | ...input, 115 | query, 116 | params: query, 117 | headers, 118 | }; 119 | } 120 | 121 | function mergeHeaders( 122 | input: HeadersInit | undefined, 123 | defaults: HeadersInit | undefined, 124 | Headers: typeof globalThis.Headers 125 | ): Headers { 126 | if (!defaults) { 127 | return new Headers(input); 128 | } 129 | const headers = new Headers(defaults); 130 | if (input) { 131 | for (const [key, value] of Symbol.iterator in input || Array.isArray(input) 132 | ? input 133 | : new Headers(input)) { 134 | headers.set(key, value); 135 | } 136 | } 137 | return headers; 138 | } 139 | 140 | export async function callHooks( 141 | context: C, 142 | hooks: FetchHook | FetchHook[] | undefined 143 | ): Promise { 144 | if (hooks) { 145 | if (Array.isArray(hooks)) { 146 | for (const hook of hooks) { 147 | await hook(context); 148 | } 149 | } else { 150 | await hooks(context); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from "node:stream"; 2 | import { listen } from "listhen"; 3 | import { getQuery, joinURL } from "ufo"; 4 | import { 5 | createApp, 6 | createError, 7 | eventHandler, 8 | readBody, 9 | readRawBody, 10 | toNodeListener, 11 | } from "h3"; 12 | import { 13 | describe, 14 | beforeEach, 15 | beforeAll, 16 | afterAll, 17 | it, 18 | expect, 19 | vi, 20 | } from "vitest"; 21 | import { Headers, FormData, Blob } from "node-fetch-native"; 22 | import { nodeMajorVersion } from "std-env"; 23 | import { $fetch } from "../src/node"; 24 | 25 | describe("ofetch", () => { 26 | let listener; 27 | const getURL = (url) => joinURL(listener.url, url); 28 | 29 | const fetch = vi.spyOn(globalThis, "fetch"); 30 | 31 | beforeAll(async () => { 32 | const app = createApp() 33 | .use( 34 | "/ok", 35 | eventHandler(() => "ok") 36 | ) 37 | .use( 38 | "/params", 39 | eventHandler((event) => getQuery(event.node.req.url || "")) 40 | ) 41 | .use( 42 | "/url", 43 | eventHandler((event) => event.node.req.url) 44 | ) 45 | .use( 46 | "/echo", 47 | eventHandler(async (event) => ({ 48 | path: event.path, 49 | body: 50 | event.node.req.method === "POST" 51 | ? await readRawBody(event) 52 | : undefined, 53 | headers: event.node.req.headers, 54 | })) 55 | ) 56 | .use( 57 | "/post", 58 | eventHandler(async (event) => ({ 59 | body: await readBody(event), 60 | headers: event.node.req.headers, 61 | })) 62 | ) 63 | .use( 64 | "/binary", 65 | eventHandler((event) => { 66 | event.node.res.setHeader("Content-Type", "application/octet-stream"); 67 | return new Blob(["binary"]); 68 | }) 69 | ) 70 | .use( 71 | "/403", 72 | eventHandler(() => 73 | createError({ status: 403, statusMessage: "Forbidden" }) 74 | ) 75 | ) 76 | .use( 77 | "/408", 78 | eventHandler(() => createError({ status: 408 })) 79 | ) 80 | .use( 81 | "/204", 82 | eventHandler(() => null) // eslint-disable-line unicorn/no-null 83 | ) 84 | .use( 85 | "/timeout", 86 | eventHandler(async () => { 87 | await new Promise((resolve) => { 88 | setTimeout(() => { 89 | resolve(createError({ status: 408 })); 90 | }, 1000 * 5); 91 | }); 92 | }) 93 | ); 94 | 95 | listener = await listen(toNodeListener(app)); 96 | }); 97 | 98 | afterAll(() => { 99 | listener.close().catch(console.error); 100 | }); 101 | 102 | beforeEach(() => { 103 | fetch.mockClear(); 104 | }); 105 | 106 | it("ok", async () => { 107 | expect(await $fetch(getURL("ok"))).to.equal("ok"); 108 | }); 109 | 110 | it("custom parseResponse", async () => { 111 | let called = 0; 112 | const parser = (r) => { 113 | called++; 114 | return "C" + r; 115 | }; 116 | expect(await $fetch(getURL("ok"), { parseResponse: parser })).to.equal( 117 | "Cok" 118 | ); 119 | expect(called).to.equal(1); 120 | }); 121 | 122 | it("allows specifying FetchResponse method", async () => { 123 | expect( 124 | await $fetch(getURL("params?test=true"), { responseType: "json" }) 125 | ).to.deep.equal({ test: "true" }); 126 | expect( 127 | await $fetch(getURL("params?test=true"), { responseType: "blob" }) 128 | ).to.be.instanceOf(Blob); 129 | expect( 130 | await $fetch(getURL("params?test=true"), { responseType: "text" }) 131 | ).to.equal('{"test":"true"}'); 132 | expect( 133 | await $fetch(getURL("params?test=true"), { responseType: "arrayBuffer" }) 134 | ).to.be.instanceOf(ArrayBuffer); 135 | }); 136 | 137 | it("returns a blob for binary content-type", async () => { 138 | expect(await $fetch(getURL("binary"))).to.be.instanceOf(Blob); 139 | }); 140 | 141 | it("baseURL", async () => { 142 | expect(await $fetch("/x?foo=123", { baseURL: getURL("url") })).to.equal( 143 | "/x?foo=123" 144 | ); 145 | }); 146 | 147 | it("stringifies posts body automatically", async () => { 148 | const { body } = await $fetch(getURL("post"), { 149 | method: "POST", 150 | body: { num: 42 }, 151 | }); 152 | expect(body).to.deep.eq({ num: 42 }); 153 | 154 | const body2 = ( 155 | await $fetch(getURL("post"), { 156 | method: "POST", 157 | body: [{ num: 42 }, { num: 43 }], 158 | }) 159 | ).body; 160 | expect(body2).to.deep.eq([{ num: 42 }, { num: 43 }]); 161 | 162 | let body3; 163 | await $fetch(getURL("post"), { 164 | method: "POST", 165 | headers: { 166 | "Content-Type": "application/x-www-form-urlencoded", 167 | }, 168 | body: { num: 42 }, 169 | onResponse(ctx) { 170 | body3 = ctx.options.body; 171 | }, 172 | }); 173 | expect(body3).equals("num=42"); 174 | 175 | const headerFetches = [ 176 | [["X-header", "1"]], 177 | { "x-header": "1" }, 178 | new Headers({ "x-header": "1" }), 179 | ]; 180 | 181 | for (const sentHeaders of headerFetches) { 182 | const { headers } = await $fetch(getURL("post"), { 183 | method: "POST", 184 | body: { num: 42 }, 185 | headers: sentHeaders as HeadersInit, 186 | }); 187 | expect(headers).to.include({ "x-header": "1" }); 188 | expect(headers).to.include({ accept: "application/json" }); 189 | } 190 | }); 191 | 192 | it("does not stringify body when content type != application/json", async () => { 193 | const message = '"Hallo von Pascal"'; 194 | const { body } = await $fetch(getURL("echo"), { 195 | method: "POST", 196 | body: message, 197 | headers: { "Content-Type": "text/plain" }, 198 | }); 199 | expect(body).to.deep.eq(message); 200 | }); 201 | 202 | it("Handle Buffer body", async () => { 203 | const message = "Hallo von Pascal"; 204 | const { body } = await $fetch(getURL("echo"), { 205 | method: "POST", 206 | body: Buffer.from("Hallo von Pascal"), 207 | headers: { "Content-Type": "text/plain" }, 208 | }); 209 | expect(body).to.deep.eq(message); 210 | }); 211 | 212 | it.skipIf(Number(nodeMajorVersion) < 18)( 213 | "Handle ReadableStream body", 214 | async () => { 215 | const message = "Hallo von Pascal"; 216 | const { body } = await $fetch(getURL("echo"), { 217 | method: "POST", 218 | headers: { 219 | "content-length": "16", 220 | }, 221 | body: new ReadableStream({ 222 | start(controller) { 223 | controller.enqueue(new TextEncoder().encode(message)); 224 | controller.close(); 225 | }, 226 | }), 227 | }); 228 | expect(body).to.deep.eq(message); 229 | } 230 | ); 231 | 232 | it.skipIf(Number(nodeMajorVersion) < 18)("Handle Readable body", async () => { 233 | const message = "Hallo von Pascal"; 234 | const { body } = await $fetch(getURL("echo"), { 235 | method: "POST", 236 | headers: { 237 | "content-length": "16", 238 | }, 239 | body: new Readable({ 240 | read() { 241 | this.push(message); 242 | this.push(null); // eslint-disable-line unicorn/no-null 243 | }, 244 | }), 245 | }); 246 | expect(body).to.deep.eq(message); 247 | }); 248 | 249 | it("Bypass FormData body", async () => { 250 | const data = new FormData(); 251 | data.append("foo", "bar"); 252 | const { body } = await $fetch(getURL("post"), { 253 | method: "POST", 254 | body: data, 255 | }); 256 | expect(body).to.include('form-data; name="foo"'); 257 | }); 258 | 259 | it("Bypass URLSearchParams body", async () => { 260 | const data = new URLSearchParams({ foo: "bar" }); 261 | const { body } = await $fetch(getURL("post"), { 262 | method: "POST", 263 | body: data, 264 | }); 265 | expect(body).toMatchObject({ foo: "bar" }); 266 | }); 267 | 268 | it("404", async () => { 269 | const error = await $fetch(getURL("404")).catch((error_) => error_); 270 | expect(error.toString()).to.contain("Cannot find any path matching /404."); 271 | expect(error.data).to.deep.eq({ 272 | stack: [], 273 | statusCode: 404, 274 | statusMessage: "Cannot find any path matching /404.", 275 | }); 276 | expect(error.response?._data).to.deep.eq(error.data); 277 | expect(error.request).to.equal(getURL("404")); 278 | }); 279 | 280 | it("403 with ignoreResponseError", async () => { 281 | const res = await $fetch(getURL("403"), { ignoreResponseError: true }); 282 | expect(res?.statusCode).to.eq(403); 283 | expect(res?.statusMessage).to.eq("Forbidden"); 284 | }); 285 | 286 | it("204 no content", async () => { 287 | const res = await $fetch(getURL("204")); 288 | expect(res).toBeUndefined(); 289 | }); 290 | 291 | it("HEAD no content", async () => { 292 | const res = await $fetch(getURL("/ok"), { method: "HEAD" }); 293 | expect(res).toBeUndefined(); 294 | }); 295 | 296 | it("baseURL with retry", async () => { 297 | const error = await $fetch("", { baseURL: getURL("404"), retry: 3 }).catch( 298 | (error_) => error_ 299 | ); 300 | expect(error.request).to.equal(getURL("404")); 301 | }); 302 | 303 | it("retry with number delay", async () => { 304 | const slow = $fetch(getURL("408"), { 305 | retry: 2, 306 | retryDelay: 100, 307 | }).catch(() => "slow"); 308 | const fast = $fetch(getURL("408"), { 309 | retry: 2, 310 | retryDelay: 1, 311 | }).catch(() => "fast"); 312 | 313 | const race = await Promise.race([slow, fast]); 314 | expect(race).to.equal("fast"); 315 | }); 316 | 317 | it("retry with callback delay", async () => { 318 | const slow = $fetch(getURL("408"), { 319 | retry: 2, 320 | retryDelay: () => 100, 321 | }).catch(() => "slow"); 322 | const fast = $fetch(getURL("408"), { 323 | retry: 2, 324 | retryDelay: () => 1, 325 | }).catch(() => "fast"); 326 | 327 | const race = await Promise.race([slow, fast]); 328 | expect(race).to.equal("fast"); 329 | }); 330 | 331 | it("abort with retry", () => { 332 | const controller = new AbortController(); 333 | async function abortHandle() { 334 | controller.abort(); 335 | const response = await $fetch("", { 336 | baseURL: getURL("ok"), 337 | retry: 3, 338 | signal: controller.signal, 339 | }); 340 | console.log("response", response); 341 | } 342 | expect(abortHandle()).rejects.toThrow(/aborted/); 343 | }); 344 | 345 | it("passing request obj should return request obj in error", async () => { 346 | const error = await $fetch(getURL("/403"), { method: "post" }).catch( 347 | (error) => error 348 | ); 349 | expect(error.toString()).toBe( 350 | 'FetchError: [POST] "http://localhost:3000/403": 403 Forbidden' 351 | ); 352 | expect(error.request).to.equal(getURL("403")); 353 | expect(error.options.method).to.equal("POST"); 354 | expect(error.response?._data).to.deep.eq(error.data); 355 | }); 356 | 357 | it("aborting on timeout", async () => { 358 | const noTimeout = $fetch(getURL("timeout")).catch(() => "no timeout"); 359 | const timeout = $fetch(getURL("timeout"), { 360 | timeout: 100, 361 | retry: 0, 362 | }).catch(() => "timeout"); 363 | const race = await Promise.race([noTimeout, timeout]); 364 | expect(race).to.equal("timeout"); 365 | }); 366 | 367 | it("aborting on timeout reason", async () => { 368 | await $fetch(getURL("timeout"), { 369 | timeout: 100, 370 | retry: 0, 371 | }).catch((error) => { 372 | expect(error.cause.message).to.include( 373 | "The operation was aborted due to timeout" 374 | ); 375 | expect(error.cause.name).to.equal("TimeoutError"); 376 | expect(error.cause.code).to.equal(DOMException.TIMEOUT_ERR); 377 | }); 378 | }); 379 | 380 | it("deep merges defaultOptions", async () => { 381 | const _customFetch = $fetch.create({ 382 | query: { 383 | a: 0, 384 | }, 385 | params: { 386 | b: 2, 387 | }, 388 | headers: { 389 | "x-header-a": "0", 390 | "x-header-b": "2", 391 | }, 392 | }); 393 | const { headers, path } = await _customFetch(getURL("echo"), { 394 | query: { 395 | a: 1, 396 | }, 397 | params: { 398 | c: 3, 399 | }, 400 | headers: { 401 | "Content-Type": "text/plain", 402 | "x-header-a": "1", 403 | "x-header-c": "3", 404 | }, 405 | }); 406 | 407 | expect(headers).to.include({ 408 | "x-header-a": "1", 409 | "x-header-b": "2", 410 | "x-header-c": "3", 411 | }); 412 | 413 | const parseParams = (str: string) => 414 | Object.fromEntries(new URLSearchParams(str).entries()); 415 | expect(parseParams(path)).toMatchObject(parseParams("?b=2&c=3&a=1")); 416 | }); 417 | 418 | it("uses request headers", async () => { 419 | expect( 420 | await $fetch( 421 | new Request(getURL("echo"), { headers: { foo: "1" } }), 422 | {} 423 | ).then((r) => r.headers) 424 | ).toMatchObject({ foo: "1" }); 425 | 426 | expect( 427 | await $fetch(new Request(getURL("echo"), { headers: { foo: "1" } }), { 428 | headers: { foo: "2", bar: "3" }, 429 | }).then((r) => r.headers) 430 | ).toMatchObject({ foo: "2", bar: "3" }); 431 | }); 432 | 433 | it("hook errors", async () => { 434 | // onRequest 435 | await expect( 436 | $fetch(getURL("/ok"), { 437 | onRequest: () => { 438 | throw new Error("error in onRequest"); 439 | }, 440 | }) 441 | ).rejects.toThrow("error in onRequest"); 442 | 443 | // onRequestError 444 | await expect( 445 | $fetch("/" /* non absolute is not acceptable */, { 446 | onRequestError: () => { 447 | throw new Error("error in onRequestError"); 448 | }, 449 | }) 450 | ).rejects.toThrow("error in onRequestError"); 451 | 452 | // onResponse 453 | await expect( 454 | $fetch(getURL("/ok"), { 455 | onResponse: () => { 456 | throw new Error("error in onResponse"); 457 | }, 458 | }) 459 | ).rejects.toThrow("error in onResponse"); 460 | 461 | // onResponseError 462 | await expect( 463 | $fetch(getURL("/403"), { 464 | onResponseError: () => { 465 | throw new Error("error in onResponseError"); 466 | }, 467 | }) 468 | ).rejects.toThrow("error in onResponseError"); 469 | }); 470 | 471 | it("calls hooks", async () => { 472 | const onRequest = vi.fn(); 473 | const onRequestError = vi.fn(); 474 | const onResponse = vi.fn(); 475 | const onResponseError = vi.fn(); 476 | 477 | await $fetch(getURL("/ok"), { 478 | onRequest, 479 | onRequestError, 480 | onResponse, 481 | onResponseError, 482 | }); 483 | 484 | expect(onRequest).toHaveBeenCalledOnce(); 485 | expect(onRequestError).not.toHaveBeenCalled(); 486 | expect(onResponse).toHaveBeenCalledOnce(); 487 | expect(onResponseError).not.toHaveBeenCalled(); 488 | 489 | onRequest.mockReset(); 490 | onRequestError.mockReset(); 491 | onResponse.mockReset(); 492 | onResponseError.mockReset(); 493 | 494 | await $fetch(getURL("/403"), { 495 | onRequest, 496 | onRequestError, 497 | onResponse, 498 | onResponseError, 499 | }).catch((error) => error); 500 | 501 | expect(onRequest).toHaveBeenCalledOnce(); 502 | expect(onRequestError).not.toHaveBeenCalled(); 503 | expect(onResponse).toHaveBeenCalledOnce(); 504 | expect(onResponseError).toHaveBeenCalledOnce(); 505 | 506 | onRequest.mockReset(); 507 | onRequestError.mockReset(); 508 | onResponse.mockReset(); 509 | onResponseError.mockReset(); 510 | 511 | await $fetch(getURL("/ok"), { 512 | onRequest: [onRequest, onRequest], 513 | onRequestError: [onRequestError, onRequestError], 514 | onResponse: [onResponse, onResponse], 515 | onResponseError: [onResponseError, onResponseError], 516 | }); 517 | 518 | expect(onRequest).toHaveBeenCalledTimes(2); 519 | expect(onRequestError).not.toHaveBeenCalled(); 520 | expect(onResponse).toHaveBeenCalledTimes(2); 521 | expect(onResponseError).not.toHaveBeenCalled(); 522 | 523 | onRequest.mockReset(); 524 | onRequestError.mockReset(); 525 | onResponse.mockReset(); 526 | onResponseError.mockReset(); 527 | 528 | await $fetch(getURL("/403"), { 529 | onRequest: [onRequest, onRequest], 530 | onRequestError: [onRequestError, onRequestError], 531 | onResponse: [onResponse, onResponse], 532 | onResponseError: [onResponseError, onResponseError], 533 | }).catch((error) => error); 534 | 535 | expect(onRequest).toHaveBeenCalledTimes(2); 536 | expect(onRequestError).not.toHaveBeenCalled(); 537 | expect(onResponse).toHaveBeenCalledTimes(2); 538 | expect(onResponseError).toHaveBeenCalledTimes(2); 539 | }); 540 | 541 | it("default fetch options", async () => { 542 | await $fetch("https://jsonplaceholder.typicode.com/todos/1", {}); 543 | expect(fetch).toHaveBeenCalledOnce(); 544 | const options = fetch.mock.calls[0][1]; 545 | expect(options).toStrictEqual({ 546 | headers: expect.any(Headers), 547 | }); 548 | }); 549 | }); 550 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "strict": true, 9 | "declaration": true, 10 | "types": ["node"] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | include: ["src"], 7 | reporter: ["text", "clover", "json"], 8 | }, 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------