├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── funding.yml ├── settings.yml └── workflows │ ├── ci.yml │ └── size-limit.yml ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── index.spec.ts └── nuxtWrap.spec.ts ├── _config.yml ├── api-extractor.json ├── index.cjs ├── index.js ├── nuxt-module ├── .editorconfig ├── .github │ └── workflows │ │ └── ci.yml ├── .gitignore ├── README.md ├── eslint.config.mjs ├── package.json ├── playground │ ├── app.vue │ ├── nuxt.config.ts │ ├── package.json │ ├── server │ │ └── tsconfig.json │ └── tsconfig.json ├── src │ ├── module.ts │ └── runtime │ │ ├── plugin.ts │ │ └── server │ │ └── tsconfig.json ├── test │ ├── basic.test.ts │ └── fixtures │ │ └── basic │ │ ├── app.vue │ │ ├── nuxt.config.ts │ │ └── package.json ├── tsconfig.json └── vitest.config.ts ├── nuxt ├── index.d.ts ├── index.js └── plugin.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── rollup.config.mjs ├── scripts └── release.sh ├── size-check └── index.js ├── src ├── index.ts ├── interceptors.ts └── mande.ts ├── tsconfig.json ├── typedoc.js ├── vitest.config.ts └── vitest.workspace.json /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome and will be fully credited! 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/{{ githubAccount }}/{{ name }}). 6 | 7 | ## Pull Requests 8 | 9 | Here are some guidelines to make the process smoother: 10 | 11 | - **Add a test** - New features and bugfixes need tests. If you find it difficult to test, please tell us in the pull request and we will try to help you! 12 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 13 | - **Run `npm test` locally** - This will allow you to go faster 14 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 15 | - **Send coherent history** - Make sure your commits message means something 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | ## Creating issues 19 | 20 | ### Bug reports 21 | 22 | Always try to provide as much information as possible. If you are reporting a bug, try to provide a repro on jsfiddle.net (or anything else) or a stacktrace at the very least. This will help us check the problem quicker. 23 | 24 | ### Feature requests 25 | 26 | Lay out the reasoning behind it and propose an API for it. Ideally, you should have a practical example to prove the utility of the feature you're requesting. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | **What kind of change does this PR introduce?** (check at least one) 10 | 11 | - [ ] Bugfix 12 | - [ ] Feature 13 | - [ ] Code style update 14 | - [ ] Refactor 15 | - [ ] Build-related changes 16 | - [ ] Other, please describe: 17 | 18 | **Does this PR introduce a breaking change?** (check one) 19 | 20 | - [ ] Yes 21 | - [ ] No 22 | 23 | If yes, please describe the impact and migration path for existing applications: 24 | 25 | **The PR fulfills these requirements:** 26 | 27 | - [ ] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number) 28 | - [ ] All tests are passing 29 | - [ ] New/updated tests are included 30 | 31 | If adding a **new feature**, the PR's description includes: 32 | 33 | - [ ] A convincing reason for adding this feature (to avoid wasting your time, it's best to open a suggestion issue first and wait for approval before working on it) 34 | 35 | **Other information:** 36 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: posva 2 | custom: https://www.paypal.me/posva 3 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | labels: 2 | - name: bug 3 | color: ee0701 4 | - name: contribution welcome 5 | color: 0e8a16 6 | - name: discussion 7 | color: 4935ad 8 | - name: docs 9 | color: 8be281 10 | - name: enhancement 11 | color: a2eeef 12 | - name: good first issue 13 | color: 7057ff 14 | - name: help wanted 15 | color: 008672 16 | - name: question 17 | color: d876e3 18 | - name: wontfix 19 | color: ffffff 20 | - name: WIP 21 | color: ffffff 22 | - name: need repro 23 | color: c9581c 24 | - name: feature request 25 | color: fbca04 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: main 6 | paths-ignore: 7 | - 'docs/**' 8 | pull_request: 9 | branches: main 10 | paths-ignore: 11 | - 'docs/**' 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: '>=22.16.0' 23 | cache: 'pnpm' 24 | 25 | - run: pnpm install 26 | - run: pnpm run lint 27 | - run: pnpm -C nuxt-module dev:prepare 28 | - run: pnpm run test:unit --coverage 29 | - run: pnpm run build 30 | - run: pnpm run build:dts 31 | - run: pnpm run size 32 | 33 | - uses: codecov/codecov-action@v5 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/size-limit.yml: -------------------------------------------------------------------------------- 1 | name: 'size' 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | size: 8 | runs-on: ubuntu-latest 9 | env: 10 | CI_JOB_NUMBER: 1 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: andresz1/size-limit-action@v1.8.0 14 | with: 15 | github_token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | npm-debug.log 4 | yarn-error.log 5 | .nyc_output 6 | coverage.lcov 7 | dist 8 | package-lock.json 9 | .DS_Store 10 | temp 11 | docs-api 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | semi: false, 3 | trailingComma: 'es5', 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.formatOnSave": false 4 | }, 5 | "[typescript]": { 6 | "editor.formatOnSave": false 7 | }, 8 | "eslint.validate": [ 9 | "javascript", 10 | "typescript" 11 | ], 12 | "eslint.useFlatConfig": true, 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": "explicit" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.9](https://github.com/posva/mande/compare/v2.0.8...v2.0.9) (2024-05-12) 2 | 3 | ### Reverts 4 | 5 | - smaller ([9f09df6](https://github.com/posva/mande/commit/9f09df678bd62aec36bc474535c405444267ec64)) 6 | 7 | ## [2.0.8](https://github.com/posva/mande/compare/v2.0.7...v2.0.8) (2023-10-03) 8 | 9 | ### Bug Fixes 10 | 11 | - correct prod mjs in package.json ([9272afb](https://github.com/posva/mande/commit/9272afb9f36dccefce2de02e138e0b5ecb515e07)) 12 | - keep FormData as is ([3b106f4](https://github.com/posva/mande/commit/3b106f44e1b3137dc9e885b11f910872bb840274)) 13 | 14 | ## [2.0.7](https://github.com/posva/mande/compare/v2.0.6...v2.0.7) (2023-07-25) 15 | 16 | ### Bug Fixes 17 | 18 | - **types:** specify types in package ([7678c66](https://github.com/posva/mande/commit/7678c665668263be60edb6bebc0fbd9aa50e1631)), closes [#401](https://github.com/posva/mande/issues/401) 19 | 20 | ### Features 21 | 22 | - allow stringify option ([afd0c5e](https://github.com/posva/mande/commit/afd0c5e22fc3ac81423725b67a46e12d3541d29d)), closes [#399](https://github.com/posva/mande/issues/399) [#382](https://github.com/posva/mande/issues/382) 23 | 24 | ## [2.0.6](https://github.com/posva/mande/compare/v2.0.5...v2.0.6) (2023-02-28) 25 | 26 | ### Bug Fixes 27 | 28 | - work with test mocks ([4856726](https://github.com/posva/mande/commit/4856726b6d3c4e12e9cb36cd7900681607adcd52)) 29 | 30 | ## [2.0.5](https://github.com/posva/mande/compare/v2.0.3...v2.0.5) (2023-02-27) 31 | 32 | ### Bug Fixes 33 | 34 | - allow no url in get and delete ([#379](https://github.com/posva/mande/issues/379)) ([b5f48b7](https://github.com/posva/mande/commit/b5f48b7fedb506768a37da063622d5e66fcc1f12)) 35 | 36 | ## [2.0.3](https://github.com/posva/mande/compare/v2.0.2...v2.0.3) (2022-08-10) 37 | 38 | ### Bug Fixes 39 | 40 | - **url:** do not add trailing slashes ([264d3bf](https://github.com/posva/mande/commit/264d3bfd63626dc94c0b5395ca879eb5540064f5)), closes [#328](https://github.com/posva/mande/issues/328) [#333](https://github.com/posva/mande/issues/333) 41 | 42 | ## [2.0.2](https://github.com/posva/mande/compare/v2.0.1...v2.0.2) (2022-05-27) 43 | 44 | ### Bug Fixes 45 | 46 | - **types:** better overrides ([9e276eb](https://github.com/posva/mande/commit/9e276eb476283a9e54eaec09c783ea0b6204db62)), closes [#324](https://github.com/posva/mande/issues/324) 47 | 48 | ## [2.0.1](https://github.com/posva/mande/compare/v2.0.0...v2.0.1) (2022-03-25) 49 | 50 | ### Bug Fixes 51 | 52 | - return raw response with status 204 and responseAs response ([#316](https://github.com/posva/mande/issues/316)) ([8a24d69](https://github.com/posva/mande/commit/8a24d699eddcd8557bc21e31e98487066d32b383)) 53 | 54 | # [2.0.0](https://github.com/posva/mande/compare/v1.0.1...v2.0.0) (2022-03-24) 55 | 56 | ### Build System 57 | 58 | - module build with mjs and cjs ([9026756](https://github.com/posva/mande/commit/90267563338fc85a4755051c6dd8d56b59f543f6)) 59 | 60 | ### BREAKING CHANGES 61 | 62 | - dist files have been renamed with cjs/mjs extensions. 63 | If you were explicitly referring dist files, you need to update the 64 | imports. Otherwise, you probably don't need to change anything. 65 | 66 | ## [1.0.1](https://github.com/posva/mande/compare/v1.0.0...v1.0.1) (2021-12-20) 67 | 68 | ### Bug Fixes 69 | 70 | - **types:** handle responseAs ([725ab77](https://github.com/posva/mande/commit/725ab770546b43720133f27a202c089c393742d0)) 71 | 72 | ### Features 73 | 74 | - explicitly forbid AbortSignal to mande ([b34c5cb](https://github.com/posva/mande/commit/b34c5cb36ed83ff2c16fab1d4d7a53b1cc2a9607)) 75 | 76 | # [1.0.0](https://github.com/posva/mande/compare/v0.0.23...v1.0.0) (2021-03-24) 77 | 78 | This version has no breaking changes. `mande` has reached a stable state and it makes sense to have a v1 now. 79 | 80 | ### Bug Fixes 81 | 82 | - remove null headers ([#210](https://github.com/posva/mande/issues/210)) ([32fcd3e](https://github.com/posva/mande/commit/32fcd3ec83f6bd07c68e0b25ef5c222dc08b258d)) 83 | 84 | ## [0.0.23](https://github.com/posva/mande/compare/v0.0.22...v0.0.23) (2020-08-10) 85 | 86 | ### Bug Fixes 87 | 88 | - **nuxt:** missing cookies ([c9b02d2](https://github.com/posva/mande/commit/c9b02d25ce4511e1611b4f38972038c144c2428e)) 89 | 90 | ## [0.0.22](https://github.com/posva/mande/compare/v0.0.21...v0.0.22) (2020-08-09) 91 | 92 | ### Features 93 | 94 | - serialize errors body in body ([e2757d5](https://github.com/posva/mande/commit/e2757d581d5a8821bc0842d462a7832341b93f71)) 95 | 96 | ## [0.0.21](https://github.com/posva/mande/compare/v0.0.20...v0.0.21) (2020-08-05) 97 | 98 | ### Bug Fixes 99 | 100 | - **nuxt:** reject the call if failed ([62b00da](https://github.com/posva/mande/commit/62b00da12b49d3986ffb217ad5bcf9ef17f0f6da)) 101 | 102 | ## [0.0.20](https://github.com/posva/mande/compare/v0.0.19...v0.0.20) (2020-07-28) 103 | 104 | ### Features 105 | 106 | - **nuxt:** pass errors to ctx.error ([b71b043](https://github.com/posva/mande/commit/b71b043a526d7d1719d072f4f60e86d632b46082)) 107 | 108 | ## [0.0.19](https://github.com/posva/mande/compare/v0.0.18...v0.0.19) (2020-07-28) 109 | 110 | ### Bug Fixes 111 | 112 | - **types:** missing rename ([f1574a0](https://github.com/posva/mande/commit/f1574a067a8e890b5e4c7a4a676205ff8f0ec7a8)) 113 | 114 | ## [0.0.18](https://github.com/posva/mande/compare/v0.0.17...v0.0.18) (2020-07-28) 115 | 116 | ### Bug Fixes 117 | 118 | - **types:** correct nuxt types + docs ([5d08bdb](https://github.com/posva/mande/commit/5d08bdb61c6199daa6d75caf9a0332cf9bc7ff9a)) 119 | 120 | ## [0.0.17](https://github.com/posva/mande/compare/v0.0.16...v0.0.17) (2020-07-27) 121 | 122 | ### Bug Fixes 123 | 124 | - types again ([cfc073c](https://github.com/posva/mande/commit/cfc073c02dc461c4263ba59ce6c3200f56bf64e3)) 125 | 126 | ## [0.0.16](https://github.com/posva/mande/compare/v0.0.15...v0.0.16) (2020-07-27) 127 | 128 | ### Bug Fixes 129 | 130 | - missing type ([ab46705](https://github.com/posva/mande/commit/ab46705bd2d83a3085f9b64b35ecb99c283e068c)) 131 | 132 | ## [0.0.15](https://github.com/posva/mande/compare/v0.0.14...v0.0.15) (2020-07-27) 133 | 134 | ### Bug Fixes 135 | 136 | - **nuxt:** missing export ([ed3534f](https://github.com/posva/mande/commit/ed3534fccc5ee3a5cf41fc2c219f9b5efcda0939)) 137 | 138 | ## [0.0.14](https://github.com/posva/mande/compare/v0.0.13...v0.0.14) (2020-07-27) 139 | 140 | ### Bug Fixes 141 | 142 | - **nuxt:** pass correct array ([ecb88ce](https://github.com/posva/mande/commit/ecb88ce8aebb18cde2c9c3b23257f1480297157c)) 143 | 144 | ## [0.0.13](https://github.com/posva/mande/compare/v0.0.12...v0.0.13) (2020-07-27) 145 | 146 | ### Bug Fixes 147 | 148 | - **nuxt:** avoid hot reload error ([d47e46d](https://github.com/posva/mande/commit/d47e46d6a8817c075be9398d0102aa9d886af29e)) 149 | - ignore some headers when proxying ([f99d313](https://github.com/posva/mande/commit/f99d313c61e360ea5e6a5f19ce4d198e1fd4875a)) 150 | 151 | ## [0.0.12](https://github.com/posva/mande/compare/v0.0.11...v0.0.12) (2020-07-23) 152 | 153 | ### Features 154 | 155 | - **nuxt:** add buildModule ([1a5b6df](https://github.com/posva/mande/commit/1a5b6df7872f3c504a6948158b21cceed5dd3da6)) 156 | 157 | ## [0.0.11](https://github.com/posva/mande/compare/v0.0.10...v0.0.11) (2020-07-23) 158 | 159 | ### Features 160 | 161 | - **nuxt:** add nuxt plugin to copy headers ([060fe9d](https://github.com/posva/mande/commit/060fe9da2e93be8b3db3a6399bdeb4c3f03876b2)) 162 | 163 | ## [0.0.10](https://github.com/posva/mande/compare/v0.0.9...v0.0.10) (2020-06-26) 164 | 165 | ### Features 166 | 167 | - allow fetch polyfill ([a6bb3f4](https://github.com/posva/mande/commit/a6bb3f4859bb7629382b88091a32bb29fa15f695)) 168 | 169 | ## [0.0.9](https://github.com/posva/mande/compare/v0.0.8...v0.0.9) (2020-06-25) 170 | 171 | ### Bug Fixes 172 | 173 | - missing instance options ([007171e](https://github.com/posva/mande/commit/007171ef3cf9f1fb0741d0c2d573ca1532b883b6)) 174 | 175 | ## [0.0.8](https://github.com/posva/mande/compare/v0.0.7...v0.0.8) (2020-06-25) 176 | 177 | ### Features 178 | 179 | - allow passing promise type through generic ([3696cd8](https://github.com/posva/mande/commit/3696cd80b5ed1f17a4acf9fa36fad85fd6332e95)) 180 | 181 | ## [0.0.7](https://github.com/posva/mande/compare/v0.0.6...v0.0.7) (2020-05-21) 182 | 183 | ### Bug Fixes 184 | 185 | - **types:** point to the generated d.ts ([61729e2](https://github.com/posva/mande/commit/61729e209168c1ae3de034f134b292d4f5cbbca2)) 186 | 187 | ## [0.0.6](https://github.com/posva/mande/compare/v0.0.5...v0.0.6) (2020-05-18) 188 | 189 | ### Bug Fixes 190 | 191 | - use options when providing only body ([e5a9bef](https://github.com/posva/mande/commit/e5a9bef007439d42d6d6a2a7ddeffb18dd8bc34b)) 192 | 193 | ### Features 194 | 195 | - allow global defaults ([a87fdda](https://github.com/posva/mande/commit/a87fddaa1c2aea4ae05ca39bbe911d80f19cecb5)) 196 | 197 | ## [0.0.5](https://github.com/posva/mande/compare/v0.0.4...v0.0.5) (2020-05-18) 198 | 199 | ### Features 200 | 201 | - allow omitting the url in post, put, patch ([bbcdad4](https://github.com/posva/mande/commit/bbcdad4b6d99725e7ee5fe81dfcda85389950841)) 202 | 203 | ## [0.0.4](https://github.com/posva/mande/compare/v0.0.3...v0.0.4) (2020-05-17) 204 | 205 | ### Bug Fixes 206 | 207 | - allow absolute urls ([160a343](https://github.com/posva/mande/commit/160a3439cd6dcdb246f8d136f87bf52aac527f78)) 208 | 209 | ## [0.0.3](https://github.com/posva/mande/compare/v0.0.2...v0.0.3) (2020-05-17) 210 | 211 | Fixed tag 212 | 213 | ## 0.0.2 (2020-05-17) 214 | 215 | Added docs 216 | 217 | ## 0.0.1 (2020-05-17) 218 | 219 | Initial release 220 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Eduardo San Martin Morote, Typicode 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 | # mande [![ci](https://github.com/posva/mande/actions/workflows/ci.yml/badge.svg)](https://github.com/posva/mande/actions/workflows/ci.yml) [![npm package](https://badgen.net/npm/v/mande)](https://www.npmjs.com/package/mande) [![codecov](https://codecov.io/github/posva/mande/graph/badge.svg?token=ItUkHTdc2q)](https://codecov.io/github/posva/mande) [![thanks](https://badgen.net/badge/thanks/♥/pink)](https://github.com/posva/thanks) 2 | 3 | > Simple, light and extensible wrapper around fetch with smart defaults 4 | 5 | **Requires [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) support.** 6 | 7 | _mande_ has better defaults to communicate with APIs using `fetch`, so instead of writing: 8 | 9 | ```js 10 | // creating a new user 11 | fetch('/api/users', { 12 | method: 'POST', 13 | headers: { 14 | Accept: 'application/json', 15 | 'Content-Type': 'application/json', 16 | }, 17 | body: JSON.stringify({ 18 | name: 'Dio', 19 | password: 'irejectmyhumanityjojo', 20 | }), 21 | }) 22 | .then((response) => { 23 | if (response.status >= 200 && response.status < 300) { 24 | return response.json() 25 | } 26 | // reject if the response is not 2xx 27 | throw new Error(response.statusText) 28 | }) 29 | .then((user) => { 30 | // ... 31 | }) 32 | ``` 33 | 34 | You can write: 35 | 36 | ```js 37 | const users = mande('/api/users') 38 | 39 | users 40 | .post({ 41 | name: 'Dio', 42 | password: 'irejectmyhumanityjojo', 43 | }) 44 | .then((user) => { 45 | // ... 46 | }) 47 | ``` 48 | 49 | ## Installation 50 | 51 | ```sh 52 | npm install mande 53 | yarn add mande 54 | ``` 55 | 56 | ## Usage 57 | 58 | Creating a small layer to communicate to your API: 59 | 60 | ```js 61 | // api/users 62 | import { mande } from 'mande' 63 | 64 | const users = mande('/api/users', usersApiOptions) 65 | 66 | export function getUserById(id) { 67 | return users.get(id) 68 | } 69 | 70 | export function createUser(userData) { 71 | return users.post(userData) 72 | } 73 | ``` 74 | 75 | Adding _Authorization_ tokens: 76 | 77 | ```js 78 | // api/users 79 | import { mande } from 'mande' 80 | 81 | const todos = mande('/api/todos', todosApiOptions) 82 | 83 | export function setToken(token) { 84 | // todos.options will be used for all requests 85 | todos.options.headers.Authorization = 'Bearer ' + token 86 | } 87 | 88 | export function clearToken() { 89 | delete todos.options.headers.Authorization 90 | } 91 | 92 | export function createTodo(todoData) { 93 | return todo.post(todoData) 94 | } 95 | ``` 96 | 97 | ```js 98 | // In a different file, setting the token whenever the login status changes. This depends on your frontend code, for instance, some libraries like Firebase provide this kind of callback but you could use a watcher on Vue. 99 | onAuthChange((user) => { 100 | if (user) setToken(user.token) 101 | else clearToken() 102 | }) 103 | ``` 104 | 105 | You can also globally add default options to all _mande_ instances: 106 | 107 | ```js 108 | import { defaults } from 'mande' 109 | 110 | defaults.headers.Authorization = 'Bearer token' 111 | ``` 112 | 113 | To delete a header, pass `null` to the mande instance or the request: 114 | 115 | ```ts 116 | const legacy = mande('/api/v1/data', { 117 | headers: { 118 | // override all requests 119 | 'Content-Type': 'application/xml', 120 | }, 121 | }) 122 | 123 | // override only this request 124 | legacy.post(new FormData(), { 125 | headers: { 126 | // overrides Accept: 'application/json' only for this request 127 | Accept: null, 128 | 'Content-Type': null, 129 | }, 130 | }) 131 | ``` 132 | 133 | ## TypeScript 134 | 135 | All methods defined on a `mande` instance accept a type generic to type their return: 136 | 137 | ```ts 138 | const todos = mande('/api/todos', globalOptions) 139 | 140 | todos 141 | .get<{ text: string; id: number; isFinished: boolean }[]>() 142 | .then((todos) => { 143 | // todos is correctly typed 144 | }) 145 | ``` 146 | 147 | ## SSR (and Nuxt in Universal mode) 148 | 149 | To make Mande work on Server, make sure to provide a `fetch` polyfill and to use full URLs and not absolute URLs starting with `/`. For example, using `node-fetch`, you can do: 150 | 151 | ```js 152 | export const BASE_URL = process.server 153 | ? process.env.NODE_ENV !== 'production' 154 | ? 'http://localhost:3000' 155 | : 'https://example.com' 156 | : // on client, do not add the domain, so urls end up like `/api/something` 157 | '' 158 | 159 | const fetchPolyfill = process.server ? require('node-fetch') : fetch 160 | const contents = mande(BASE_URL + '/api', {}, fetchPolyfill) 161 | ``` 162 | 163 | ### Nuxt 2 164 | 165 | Note: If you are doing SSR with authentication, Nuxt 3 hasn't been adapted yet. See #308. 166 | 167 | When using with Nuxt **and SSR**, you must wrap exported functions so they automatically proxy cookies and headers on the server: 168 | 169 | ```js 170 | import { mande, nuxtWrap } from 'mande' 171 | const fetchPolyfill = process.server ? require('node-fetch') : fetch 172 | const users = mande(BASE_URL + '/api/users', {}, fetchPolyfill) 173 | 174 | export const getUserById = nuxtWrap(users, (api, id: string) => api.get(id)) 175 | ``` 176 | 177 | Make sure to add it as a buildModule as well: 178 | 179 | ```js 180 | // nuxt.config.js 181 | module.exports = { 182 | buildModules: ['mande/nuxt'], 183 | } 184 | ``` 185 | 186 | This prevents requests from accidentally sharing headers or bearer tokens. 187 | 188 | #### TypeScript 189 | 190 | Make sure to include `mande/nuxt` in your `tsconfig.json`: 191 | 192 | ```json 193 | { 194 | "types": ["@types/node", "@nuxt/types", "mande/nuxt"] 195 | } 196 | ``` 197 | 198 | ## API 199 | 200 | Most of the code can be discovered through the autocompletion but the API documentation is available at [https://mande.esm.is](https://mande.esm.is) 201 | 202 | ### Cookbook 203 | 204 | #### Timeout 205 | 206 | You can timeout requests by using the native `AbortSignal`: 207 | 208 | ```ts 209 | mande('/api').get('/users', { signal: AbortSignal.timeout(2000) }) 210 | ``` 211 | 212 | This is supported by [all modern browsers](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static). 213 | 214 | #### `FormData` 215 | 216 | When passing [Form Data](https://developer.mozilla.org/en-US/docs/Web/API/FormData), mande automatically removes the `Content-Type` header but you can manually set it if needed: 217 | 218 | ```ts 219 | // directly pass it to the mande instance 220 | const api = mande('/api/', { headers: { 'Content-Type': null } }) 221 | // or when creating the request 222 | const formData = new FormData() 223 | api.post(formData, { 224 | headers: { 'Content-Type': 'multipart/form-data' }, 225 | }) 226 | ``` 227 | 228 | Most of the time you should let the browser set it for you. 229 | 230 | ## Related 231 | 232 | - [fetchival](https://github.com/typicode/fetchival): part of the code was borrowed from it and the api is very similar 233 | - [axios](https://github.com/axios/axios): 234 | 235 | ## License 236 | 237 | [MIT](http://opensource.org/licenses/MIT) 238 | -------------------------------------------------------------------------------- /__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, expect, describe, it, expectTypeOf } from 'vitest' 2 | import { mande, defaults } from '../src' 3 | 4 | describe('mande', () => { 5 | it('calls fetch', async () => { 6 | const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()) 7 | 8 | let api = mande('/api/') 9 | await expect(api.get('')).resolves 10 | expect(spy).toHaveBeenCalledTimes(1) 11 | expect(spy).toHaveBeenCalledWith( 12 | '/api/', 13 | expect.objectContaining({ 14 | headers: { 15 | Accept: 'application/json', 16 | 'Content-Type': 'application/json', 17 | }, 18 | method: 'GET', 19 | }) 20 | ) 21 | }) 22 | 23 | it('can use a number', async () => { 24 | const spy = vi 25 | .spyOn(globalThis, 'fetch') 26 | .mockResolvedValue(Response.json({})) 27 | let api = mande('/api/') 28 | await expect(api.get(0)).resolves.toEqual({}) 29 | expect(spy).toHaveBeenCalledWith('/api/0', expect.anything()) 30 | }) 31 | 32 | it('works with non trailing slashes', async () => { 33 | const spy = vi 34 | .spyOn(globalThis, 'fetch') 35 | .mockResolvedValue(Response.json({})) 36 | let api = mande('/api/') 37 | await expect(api.get('/2')).resolves.toEqual({}) 38 | expect(spy).toHaveBeenCalledWith('/api/2', expect.anything()) 39 | }) 40 | 41 | it('can use get without parameters', async () => { 42 | const spy = vi 43 | .spyOn(globalThis, 'fetch') 44 | .mockResolvedValue(Response.json({})) 45 | let api = mande('/api/') 46 | await expect(api.get()).resolves.toEqual({}) 47 | expect(spy).toHaveBeenCalledWith('/api/', expect.anything()) 48 | }) 49 | 50 | it('allows an absolute base', async () => { 51 | const spy = vi 52 | .spyOn(globalThis, 'fetch') 53 | .mockResolvedValue(Response.json({})) 54 | let api = mande('http://example.com/api/') 55 | await expect(api.get('')).resolves.toEqual({}) 56 | expect(spy).toHaveBeenCalledWith( 57 | 'http://example.com/api/', 58 | expect.anything() 59 | ) 60 | }) 61 | 62 | it('returns null on 204', async () => { 63 | const spy = vi 64 | .spyOn(globalThis, 'fetch') 65 | .mockResolvedValue(new Response(null, { status: 200 })) 66 | let api = mande('/api/') 67 | await expect(api.get('')).resolves.toEqual(null) 68 | }) 69 | 70 | it('calls delete', async () => { 71 | const spy = vi 72 | .spyOn(globalThis, 'fetch') 73 | .mockResolvedValue(Response.json({})) 74 | let api = mande('/api/') 75 | await expect(api.delete('')).resolves.toEqual({}) 76 | expect(spy).toHaveBeenCalledWith( 77 | '/api/', 78 | expect.objectContaining({ 79 | method: 'DELETE', 80 | }) 81 | ) 82 | }) 83 | 84 | it('calls delete without parameters', async () => { 85 | const spy = vi 86 | .spyOn(globalThis, 'fetch') 87 | .mockResolvedValue(Response.json({})) 88 | let api = mande('/api/') 89 | await expect(api.delete()).resolves.toEqual({}) 90 | expect(spy.mock.calls[0][1]).not.toHaveProperty('body') 91 | }) 92 | 93 | it('rejects if not ok', async () => { 94 | const response = Response.json({}, { status: 404 }) 95 | const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response) 96 | let api = mande('/api/') 97 | await expect(api.get('')).rejects.toHaveProperty('response', response) 98 | }) 99 | 100 | it('serializes body on error', async () => { 101 | const spy = vi 102 | .spyOn(globalThis, 'fetch') 103 | .mockResolvedValue(Response.json({ message: 'nope' }, { status: 404 })) 104 | let api = mande('/api/') 105 | await expect(api.get('')).rejects.toMatchObject({ 106 | response: expect.anything(), 107 | body: { message: 'nope' }, 108 | }) 109 | }) 110 | 111 | it('works with empty failed request', async () => { 112 | const spy = vi 113 | .spyOn(globalThis, 'fetch') 114 | .mockResolvedValue(Response.json(null, { status: 404 })) 115 | let api = mande('/api/') 116 | await expect(api.get('')).rejects.toMatchObject({ 117 | response: expect.anything(), 118 | body: null, 119 | }) 120 | }) 121 | 122 | it('can pass a query', async () => { 123 | const spy = vi 124 | .spyOn(globalThis, 'fetch') 125 | .mockResolvedValue(Response.json({})) 126 | let api = mande('/api/') 127 | await expect( 128 | api.get('', { query: { foo: 'a', bar: 'b' } }) 129 | ).resolves.toEqual({}) 130 | expect(spy).toHaveBeenCalledWith('/api/?foo=a&bar=b', expect.anything()) 131 | }) 132 | 133 | it('can use get with options only', async () => { 134 | const spy = vi 135 | .spyOn(globalThis, 'fetch') 136 | .mockResolvedValue(Response.json({})) 137 | let api = mande('/api/') 138 | await expect(api.get({ query: { foo: 'a', bar: 'b' } })).resolves.toEqual( 139 | {} 140 | ) 141 | expect(spy).toHaveBeenCalledWith('/api/?foo=a&bar=b', expect.anything()) 142 | }) 143 | 144 | it('merges global query', async () => { 145 | const spy = vi 146 | .spyOn(globalThis, 'fetch') 147 | .mockResolvedValue(Response.json({})) 148 | let api = mande('/api/', { query: { foo: 'a' } }) 149 | await expect(api.get('', { query: { bar: 'b' } })).resolves.toEqual({}) 150 | expect(spy).toHaveBeenCalledWith('/api/?foo=a&bar=b', expect.anything()) 151 | }) 152 | 153 | it('can pass a body', async () => { 154 | const spy = vi 155 | .spyOn(globalThis, 'fetch') 156 | .mockResolvedValue(Response.json({})) 157 | let api = mande('/api/') 158 | await expect(api.put('', { foo: 'a', bar: 'b' })).resolves.toEqual({}) 159 | expect(spy).toHaveBeenCalledWith( 160 | '/api/', 161 | expect.objectContaining({ 162 | body: '{"foo":"a","bar":"b"}', 163 | method: 'PUT', 164 | }) 165 | ) 166 | }) 167 | 168 | it('can omit the url', async () => { 169 | const spy = vi 170 | .spyOn(globalThis, 'fetch') 171 | .mockResolvedValue(Response.json({})) 172 | let api = mande('/api/users') 173 | await expect(api.put({ foo: 'a', bar: 'b' })).resolves.toEqual({}) 174 | expect(spy).toHaveBeenCalledWith( 175 | '/api/users', 176 | expect.objectContaining({ 177 | body: '{"foo":"a","bar":"b"}', 178 | method: 'PUT', 179 | }) 180 | ) 181 | }) 182 | 183 | it('can add custom headers', async () => { 184 | const spy = vi 185 | .spyOn(globalThis, 'fetch') 186 | .mockResolvedValue(Response.json({})) 187 | let api = mande('/api/') 188 | await api.get('2', { headers: { Authorization: 'Bearer foo' } }) 189 | expect(spy).toHaveBeenCalledWith( 190 | '/api/2', 191 | expect.objectContaining({ 192 | headers: { 193 | Accept: 'application/json', 194 | 'Content-Type': 'application/json', 195 | Authorization: 'Bearer foo', 196 | }, 197 | }) 198 | ) 199 | }) 200 | 201 | it('can add custom headers through instance', async () => { 202 | const spy = vi 203 | .spyOn(globalThis, 'fetch') 204 | .mockResolvedValue(Response.json({})) 205 | let api = mande('/api/') 206 | api.options.headers.Authorization = 'token secret' 207 | await api.get('2', { headers: { other: 'foo' } }) 208 | expect(spy).toHaveBeenCalledWith( 209 | '/api/2', 210 | expect.objectContaining({ 211 | headers: { 212 | Accept: 'application/json', 213 | 'Content-Type': 'application/json', 214 | Authorization: 'token secret', 215 | other: 'foo', 216 | }, 217 | }) 218 | ) 219 | // should not fail in TS 220 | api.options.headers.Authorization = 'token secret' 221 | }) 222 | 223 | it('can remove a default header', async () => { 224 | const spy = vi 225 | .spyOn(globalThis, 'fetch') 226 | .mockResolvedValue(Response.json({})) 227 | let api = mande('/api/', { headers: { 'Content-Type': null } }) 228 | await api.get('2') 229 | expect(spy).toHaveBeenCalledWith( 230 | '/api/2', 231 | expect.objectContaining({ 232 | headers: { 233 | Accept: 'application/json', 234 | // no Content-Type 235 | 'Content-Type': undefined, 236 | }, 237 | }) 238 | ) 239 | }) 240 | 241 | it('keeps empty strings headers', async () => { 242 | const spy = vi 243 | .spyOn(globalThis, 'fetch') 244 | .mockResolvedValue(Response.json({})) 245 | let api = mande('/api/', { headers: { 'Content-Type': '' } }) 246 | await api.get('2') 247 | expect(spy).toHaveBeenCalledWith( 248 | '/api/2', 249 | expect.objectContaining({ 250 | headers: { 251 | Accept: 'application/json', 252 | 'Content-Type': '', 253 | }, 254 | }) 255 | ) 256 | }) 257 | 258 | it('can return a raw response', async () => { 259 | const response = Response.json({}) 260 | const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response) 261 | let api = mande('/api/') 262 | await api.get('', { responseAs: 'response' }).then((res) => { 263 | expectTypeOf(res) 264 | expect(res).toBe(response) 265 | }) 266 | // cannot check the result for some reason... 267 | function tds() { 268 | const noDataMethods = ['get', 'delete'] as const 269 | const dataMethods = ['post', 'put', 'patch'] as const 270 | for (const method of dataMethods) { 271 | expectTypeOf>( 272 | api[method]<{ value: number }>('/api') 273 | ) 274 | api[method]('/api').then((r) => { 275 | expectTypeOf(r) 276 | // @ts-expect-error: r is unknown 277 | r.stuff 278 | }) 279 | expectTypeOf>( 280 | api[method](2, { responseAs: 'response' }) 281 | ) 282 | expectTypeOf>(api[method](2, { responseAs: 'text' })) 283 | } 284 | 285 | for (const method of noDataMethods) { 286 | expectTypeOf>( 287 | api[method]<{ value: number }>('/api') 288 | ) 289 | api[method]('/api').then((r) => { 290 | expectTypeOf(r) 291 | // @ts-expect-error: r is unknown 292 | r.stuff 293 | }) 294 | expectTypeOf>( 295 | api[method](2, { responseAs: 'response' }) 296 | ) 297 | expectTypeOf>(api[method](2, { responseAs: 'text' })) 298 | } 299 | } 300 | }) 301 | 302 | it('can return a raw response when called without url parameter', async () => { 303 | const spy = vi 304 | .spyOn(globalThis, 'fetch') 305 | .mockResolvedValue(Response.json({})) 306 | let api = mande('/api/') 307 | await api.get({ responseAs: 'response' }).then((res) => { 308 | expect(res).not.toBeNull() 309 | expectTypeOf(res) 310 | }) 311 | }) 312 | 313 | it('can return a raw response with status code 204', async () => { 314 | const spy = vi 315 | .spyOn(globalThis, 'fetch') 316 | .mockResolvedValue(new Response(null, { status: 204 })) 317 | let api = mande('/api/') 318 | await api.get('', { responseAs: 'response' }).then((res) => { 319 | expect(res).not.toBeNull() 320 | expectTypeOf(res) 321 | }) 322 | }) 323 | 324 | it('can return a text response when called without url parameter', async () => { 325 | const spy = vi 326 | .spyOn(globalThis, 'fetch') 327 | .mockResolvedValue(Response.json({})) 328 | let api = mande('/api/') 329 | await api.get({ responseAs: 'text' }).then((res) => { 330 | expect(res).not.toBeNull() 331 | expectTypeOf(res) 332 | }) 333 | }) 334 | 335 | it('can return a raw response when delete called without url parameter', async () => { 336 | const spy = vi 337 | .spyOn(globalThis, 'fetch') 338 | .mockResolvedValue(new Response(null, { status: 204 })) 339 | let api = mande('/api/') 340 | await api.delete({ responseAs: 'response' }).then((res) => { 341 | expect(res).not.toBeNull() 342 | expectTypeOf(res) 343 | }) 344 | }) 345 | 346 | it('can return a text response when delete called without url parameter', async () => { 347 | const spy = vi 348 | .spyOn(globalThis, 'fetch') 349 | .mockResolvedValue(Response.json({})) 350 | let api = mande('/api/') 351 | await api.delete({ responseAs: 'text' }).then((res) => { 352 | expect(res).not.toBeNull() 353 | expectTypeOf(res) 354 | }) 355 | }) 356 | 357 | it('can add global defaults', async () => { 358 | const spy = vi 359 | .spyOn(globalThis, 'fetch') 360 | .mockResolvedValue(Response.json({})) 361 | let api = mande('/api/') 362 | defaults.query = { foo: 'bar' } 363 | // defaults.headers.Authorization = { foo: 'bar' } 364 | await expect(api.get('2')).resolves.toEqual({}) 365 | expect(spy).toHaveBeenCalledWith('/api/2?foo=bar', expect.anything()) 366 | delete defaults.query 367 | }) 368 | 369 | it('can be passed a signal', async () => { 370 | const controller = new AbortController() 371 | const { signal } = controller 372 | 373 | // @ts-expect-error: signal cannot be passed to mande 374 | mande('/api', { signal }) 375 | let api = mande('/api') 376 | const spy = vi 377 | .spyOn(globalThis, 'fetch') 378 | .mockResolvedValue(Response.json({})) 379 | 380 | await expect(api.get('1', { signal })).resolves.toEqual({}) 381 | expect(spy).toHaveBeenCalledWith( 382 | '/api/1', 383 | expect.objectContaining({ 384 | signal, 385 | }) 386 | ) 387 | }) 388 | 389 | it('does not add trailing slashes', async () => { 390 | const spy = vi 391 | .spyOn(globalThis, 'fetch') 392 | .mockResolvedValue(Response.json({})) 393 | let api = mande('/api') 394 | await expect(api.get('')).resolves.toEqual({}) 395 | expect(spy).toHaveBeenCalledWith('/api', expect.anything()) 396 | }) 397 | 398 | it('ensure in between slashes', async () => { 399 | const spy = vi 400 | .spyOn(globalThis, 'fetch') 401 | .mockResolvedValue(Response.json({})) 402 | let api = mande('/api') 403 | await expect(api.get('2')).resolves.toEqual({}) 404 | expect(spy).toHaveBeenCalledWith('/api/2', expect.anything()) 405 | }) 406 | 407 | it('adds explicit trailing slash', async () => { 408 | const spy = vi 409 | .spyOn(globalThis, 'fetch') 410 | .mockResolvedValue(Response.json({})) 411 | let api = mande('/api') 412 | await expect(api.get('/')).resolves.toEqual({}) 413 | expect(spy).toHaveBeenCalledWith('/api/', expect.anything()) 414 | }) 415 | 416 | it('avoids duplicated slashes', async () => { 417 | const spy = vi 418 | .spyOn(globalThis, 'fetch') 419 | .mockResolvedValue(Response.json({})) 420 | let api = mande('/api/') 421 | await expect(api.get('/2')).resolves.toEqual({}) 422 | expect(spy).toHaveBeenCalledWith('/api/2', expect.anything()) 423 | }) 424 | 425 | it('calls the stringify with data on put and post', async () => { 426 | const stringify = vi.fn().mockReturnValue({ foo: 'bar' }) 427 | 428 | const spy = vi 429 | .spyOn(globalThis, 'fetch') 430 | .mockImplementation(async () => Response.json({})) 431 | let api = mande('/api/', { stringify: stringify }) 432 | const data = {} 433 | await expect(api.post(data)).resolves.toEqual({}) 434 | expect(stringify).toHaveBeenCalledTimes(1) 435 | expect(stringify).toHaveBeenCalledWith(data) 436 | // second arg 437 | await expect(api.put('/a', data)).resolves.toEqual({}) 438 | expect(stringify).toHaveBeenCalledTimes(2) 439 | expect(stringify).toHaveBeenNthCalledWith(2, data) 440 | }) 441 | 442 | it('keeps FormData as is', async () => { 443 | const spy = vi 444 | .spyOn(globalThis, 'fetch') 445 | .mockResolvedValue(Response.json({})) 446 | let api = mande('/api/') 447 | 448 | const data = new FormData() 449 | await expect(api.post(data)).resolves.toEqual({}) 450 | expect(spy).toHaveBeenCalledWith( 451 | '/api/', 452 | expect.objectContaining({ body: data }) 453 | ) 454 | }) 455 | 456 | it('deletes Content-Type header with FormData', async () => { 457 | const spy = vi 458 | .spyOn(globalThis, 'fetch') 459 | .mockResolvedValue(Response.json({})) 460 | let api = mande('/api/') 461 | 462 | const data = new FormData() 463 | await api.post(data) 464 | expect(spy).toHaveBeenCalledWith( 465 | '/api/', 466 | expect.objectContaining({ 467 | headers: { 468 | Accept: 'application/json', 469 | 'Content-Type': undefined, 470 | }, 471 | }) 472 | ) 473 | }) 474 | }) 475 | -------------------------------------------------------------------------------- /__tests__/nuxtWrap.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, vi } from 'vitest' 2 | import { mande, nuxtWrap } from '../src' 3 | 4 | describe.skip('Nuxt wrapping', () => { 5 | it('calls fetch', async () => { 6 | const spy = vi 7 | .spyOn(globalThis, 'fetch') 8 | .mockResolvedValue(Response.json({})) 9 | let api = mande('/api/') 10 | const wrapped = nuxtWrap(api, (api, n: number) => { 11 | return api.get<{}>(n) 12 | }) 13 | await expect(wrapped(20)).resolves.toEqual({}) 14 | expect(spy).toHaveBeenCalledWith('/api/20', expect.anything()) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | // this the shared base config for all packages. 2 | { 3 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 4 | 5 | "mainEntryPointFilePath": "./dist/src/index.d.ts", 6 | 7 | "apiReport": { 8 | "enabled": true, 9 | "reportFolder": "/temp/" 10 | }, 11 | 12 | "docModel": { 13 | "enabled": true 14 | }, 15 | 16 | "dtsRollup": { 17 | "enabled": true, 18 | "untrimmedFilePath": "./dist/.d.ts" 19 | }, 20 | 21 | "tsdocMetadata": { 22 | "enabled": false 23 | }, 24 | 25 | "messages": { 26 | "compilerMessageReporting": { 27 | "default": { 28 | "logLevel": "warning" 29 | } 30 | }, 31 | 32 | "extractorMessageReporting": { 33 | "default": { 34 | "logLevel": "warning", 35 | "addToApiReportFile": true 36 | }, 37 | 38 | "ae-missing-release-tag": { 39 | "logLevel": "none" 40 | } 41 | }, 42 | 43 | "tsdocMessageReporting": { 44 | "default": { 45 | "logLevel": "warning" 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | module.exports = require('./dist/mande.prod.cjs') 5 | } else { 6 | module.exports = require('./dist/mande.cjs') 7 | } 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | module.exports = require('./dist/mande.prod.cjs') 5 | } else { 6 | module.exports = require('./dist/mande.cjs') 7 | } 8 | -------------------------------------------------------------------------------- /nuxt-module/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /nuxt-module/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - run: corepack enable 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | 22 | - name: Install dependencies 23 | run: npx nypm@latest i 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - run: corepack enable 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | 38 | - name: Install dependencies 39 | run: npx nypm@latest i 40 | 41 | - name: Playground prepare 42 | run: npm run dev:prepare 43 | 44 | - name: Test 45 | run: npm run test 46 | -------------------------------------------------------------------------------- /nuxt-module/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /nuxt-module/README.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # My Module 11 | 12 | [![npm version][npm-version-src]][npm-version-href] 13 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 14 | [![License][license-src]][license-href] 15 | [![Nuxt][nuxt-src]][nuxt-href] 16 | 17 | My new Nuxt module for doing amazing things. 18 | 19 | - [✨  Release Notes](/CHANGELOG.md) 20 | 21 | 22 | 23 | ## Features 24 | 25 | 26 | - ⛰  Foo 27 | - 🚠  Bar 28 | - 🌲  Baz 29 | 30 | ## Quick Setup 31 | 32 | Install the module to your Nuxt application with one command: 33 | 34 | ```bash 35 | npx nuxi module add my-module 36 | ``` 37 | 38 | That's it! You can now use My Module in your Nuxt app ✨ 39 | 40 | 41 | ## Contribution 42 | 43 |
44 | Local development 45 | 46 | ```bash 47 | # Install dependencies 48 | npm install 49 | 50 | # Generate type stubs 51 | npm run dev:prepare 52 | 53 | # Develop with the playground 54 | npm run dev 55 | 56 | # Build the playground 57 | npm run dev:build 58 | 59 | # Run ESLint 60 | npm run lint 61 | 62 | # Run Vitest 63 | npm run test 64 | npm run test:watch 65 | 66 | # Release new version 67 | npm run release 68 | ``` 69 | 70 |
71 | 72 | 73 | 74 | [npm-version-src]: https://img.shields.io/npm/v/my-module/latest.svg?style=flat&colorA=020420&colorB=00DC82 75 | [npm-version-href]: https://npmjs.com/package/my-module 76 | 77 | [npm-downloads-src]: https://img.shields.io/npm/dm/my-module.svg?style=flat&colorA=020420&colorB=00DC82 78 | [npm-downloads-href]: https://npmjs.com/package/my-module 79 | 80 | [license-src]: https://img.shields.io/npm/l/my-module.svg?style=flat&colorA=020420&colorB=00DC82 81 | [license-href]: https://npmjs.com/package/my-module 82 | 83 | [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js 84 | [nuxt-href]: https://nuxt.com 85 | -------------------------------------------------------------------------------- /nuxt-module/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 3 | 4 | // Run `npx @eslint/config-inspector` to inspect the resolved config interactively 5 | export default createConfigForNuxt({ 6 | features: { 7 | // Rules for module authors 8 | tooling: true, 9 | // Rules for formatting 10 | stylistic: true, 11 | }, 12 | dirs: { 13 | src: [ 14 | './playground', 15 | ], 16 | }, 17 | }) 18 | .append( 19 | // your custom flat config here... 20 | ) 21 | -------------------------------------------------------------------------------- /nuxt-module/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-module", 3 | "version": "1.0.0", 4 | "description": "My new Nuxt module", 5 | "repository": "your-org/my-module", 6 | "license": "MIT", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types.d.ts", 11 | "import": "./dist/module.mjs", 12 | "require": "./dist/module.cjs" 13 | } 14 | }, 15 | "main": "./dist/module.cjs", 16 | "types": "./dist/types.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "nuxt-module-build build", 22 | "dev": "nuxi dev playground", 23 | "dev:build": "nuxi build playground", 24 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 25 | "lint": "eslint .", 26 | "test": "vitest run", 27 | "test:watch": "vitest watch", 28 | "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit" 29 | }, 30 | "dependencies": { 31 | "@nuxt/kit": "^3.17.4" 32 | }, 33 | "devDependencies": { 34 | "@nuxt/devtools": "^2.4.1", 35 | "@nuxt/eslint-config": "^1.4.1", 36 | "@nuxt/module-builder": "^1.0.1", 37 | "@nuxt/schema": "^3.17.4", 38 | "@nuxt/test-utils": "^3.19.1", 39 | "@types/node": "^22.15.21", 40 | "eslint": "^9.27.0", 41 | "nuxt": "^3.17.4", 42 | "vue-tsc": "^2.2.10" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /nuxt-module/playground/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /nuxt-module/playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['../src/module'], 3 | myModule: {}, 4 | devtools: { enabled: true }, 5 | compatibilityDate: '2024-08-01', 6 | }) -------------------------------------------------------------------------------- /nuxt-module/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-module-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "dependencies": { 11 | "nuxt": "^3.17.4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nuxt-module/playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /nuxt-module/playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /nuxt-module/src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit' 2 | import type { NuxtModule } from 'nuxt/schema' 3 | 4 | // Module options TypeScript interface definition 5 | export interface ModuleOptions {} 6 | 7 | const mandeModule: NuxtModule = defineNuxtModule({ 8 | meta: { 9 | name: 'my-module', 10 | configKey: 'myModule', 11 | }, 12 | // Default configuration options of the Nuxt module 13 | defaults: {}, 14 | setup(_options, _nuxt) { 15 | const resolver = createResolver(import.meta.url) 16 | 17 | // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` 18 | addPlugin(resolver.resolve('./runtime/plugin')) 19 | }, 20 | }) 21 | 22 | export default mandeModule 23 | -------------------------------------------------------------------------------- /nuxt-module/src/runtime/plugin.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from '#app' 2 | 3 | export default defineNuxtPlugin((_nuxtApp) => { 4 | console.log('Plugin injected by my-module!') 5 | }) 6 | -------------------------------------------------------------------------------- /nuxt-module/src/runtime/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../.nuxt/tsconfig.server.json", 3 | } 4 | -------------------------------------------------------------------------------- /nuxt-module/test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path' 2 | import { describe, it, expect, beforeAll } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils/e2e' 4 | 5 | const __dirname = dirname(new URL(import.meta.url).pathname) 6 | 7 | describe('ssr', async () => { 8 | beforeAll(async () => { 9 | await setup({ 10 | rootDir: join(__dirname, '/fixture/basic'), 11 | }) 12 | }) 13 | 14 | it.skip('renders the index page', async () => { 15 | // Get response to a server-rendered page with `$fetch`. 16 | const html = await $fetch('/') 17 | expect(html).toContain('
basic
') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /nuxt-module/test/fixtures/basic/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /nuxt-module/test/fixtures/basic/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import MyModule from '../../../src/module' 2 | 3 | export default defineNuxtConfig({ 4 | modules: [ 5 | MyModule, 6 | ], 7 | }) 8 | -------------------------------------------------------------------------------- /nuxt-module/test/fixtures/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "basic", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /nuxt-module/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/tsconfig.json", 3 | "exclude": [ 4 | "dist", 5 | "node_modules", 6 | "playground", 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /nuxt-module/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject } from 'vitest/config' 2 | 3 | export default defineProject({ 4 | }) 5 | -------------------------------------------------------------------------------- /nuxt/index.d.ts: -------------------------------------------------------------------------------- 1 | import '@nuxt/types' 2 | 3 | declare module '@nuxt/types' { 4 | interface Context { 5 | mande: any>( 6 | fn: F, 7 | ...args: Parameters 8 | ) => ReturnType 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /nuxt/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | const DEFAULT_OPTIONS = { 4 | callError: true, 5 | proxyHeadersIgnore: [ 6 | 'accept', 7 | 'host', 8 | 'cf-ray', 9 | 'cf-connecting-ip', 10 | 'content-length', 11 | 'content-md5', 12 | 'content-type', 13 | ], 14 | } 15 | 16 | /** @type {import('@nuxt/types').Module} */ 17 | const MandeModule = function NuxtMandeModule(localOptions) { 18 | // TODO: merge arrays properly. There is probably a package to handle this 19 | const options = { 20 | ...DEFAULT_OPTIONS, 21 | ...localOptions, 22 | } 23 | 24 | this.addPlugin({ 25 | src: path.resolve(__dirname, 'plugin.js'), 26 | fileName: 'mande.js', 27 | // FIXME: figure out why options end up being undefined 28 | options, 29 | }) 30 | } 31 | 32 | module.exports = MandeModule 33 | -------------------------------------------------------------------------------- /nuxt/plugin.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@nuxt/types').Plugin} */ 2 | export default (ctx, inject) => { 3 | function mande(wrappedFn, ...args) { 4 | return ( 5 | wrappedFn( 6 | /** @type {import('../src').MandeInstance} */ 7 | (api) => { 8 | // the plugin can be called during dev in client side with no context 9 | if (!ctx.req) return 10 | 11 | const reqHeaders = { ...ctx.req.headers } 12 | 13 | const proxyHeadersIgnore = 14 | // comment easier 15 | <%= serialize(options.proxyHeadersIgnore) %> || 16 | [ 17 | 'accept', 18 | 'host', 19 | 'cf-ray', 20 | 'cf-connecting-ip', 21 | 'content-length', 22 | 'content-md5', 23 | 'content-type', 24 | ] 25 | 26 | // @ts-ignore 27 | for (let header of proxyHeadersIgnore) { 28 | delete reqHeaders[header] 29 | } 30 | 31 | // force clear any existing cookie 32 | api.options.headers = { ...api.options.headers, cookie: null, ...reqHeaders } 33 | 34 | if (process.server) { 35 | // Don't accept brotli encoding because Node can't parse it 36 | api.options.headers['accept-encoding'] = 'gzip, deflate' 37 | } 38 | }, 39 | ...args 40 | ) 41 | <% if (options.callError) { %> 42 | .catch((err) => { 43 | const errorObject = { 44 | statusCode: err.response.status, 45 | message: err.message, 46 | body: err.body, 47 | } 48 | ctx.error(errorObject) 49 | return Promise.reject(errorObject) 50 | }) 51 | <% } %> 52 | ) 53 | } 54 | 55 | ctx.mande = mande 56 | inject('mande', mande) 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mande", 3 | "version": "2.0.9", 4 | "description": "800 bytes modern wrapper around fetch with smart defaults", 5 | "packageManager": "pnpm@10.11.0", 6 | "type": "module", 7 | "main": "index.cjs", 8 | "module": "dist/mande.js", 9 | "unpkg": "dist/mande.iife.js", 10 | "jsdelivr": "dist/mande.iife.js", 11 | "types": "dist/mande.d.ts", 12 | "sideEffects": false, 13 | "exports": { 14 | ".": { 15 | "types": "./dist/mande.d.ts", 16 | "node": { 17 | "import": { 18 | "production": "./dist/mande.prod.js", 19 | "development": "./dist/mande.js", 20 | "default": "./dist/mande.js" 21 | }, 22 | "require": { 23 | "production": "./dist/mande.prod.cjs", 24 | "development": "./dist/mande.cjs", 25 | "default": "./index.cjs" 26 | } 27 | }, 28 | "import": "./dist/mande.js" 29 | }, 30 | "./package.json": "./package.json", 31 | "./dist/*": "./dist/*" 32 | }, 33 | "author": { 34 | "name": "Eduardo San Martin Morote", 35 | "email": "posva13@gmail.com" 36 | }, 37 | "scripts": { 38 | "lint": "prettier -c --parser typescript \"{src,__tests__}/**/*.[jt]s?(x)\"", 39 | "lint:fix": "pnpm run lint --write", 40 | "test:unit": "vitest", 41 | "release": "bash scripts/release.sh", 42 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 1", 43 | "dev": "vitest", 44 | "pretest": "pnpm run lint", 45 | "test": "pnpm run test:unit run && pnpm run build", 46 | "size": "size-limit", 47 | "build": "rollup -c rollup.config.mjs", 48 | "build:pkgs": "pnpm run --stream --parallel --filter './nuxt-module' build", 49 | "build:dts": "api-extractor run --local --verbose", 50 | "docs": "typedoc" 51 | }, 52 | "files": [ 53 | "index.js", 54 | "index.cjs", 55 | "dist/*.js", 56 | "dist/*.mjs", 57 | "dist/*.cjs", 58 | "dist/mande.d.ts", 59 | "nuxt/*.js", 60 | "nuxt/*.d.ts", 61 | "README.md" 62 | ], 63 | "keywords": [ 64 | "fetch", 65 | "browser", 66 | "client", 67 | "request", 68 | "api", 69 | "get", 70 | "ajax", 71 | "fetchival", 72 | "axios", 73 | "alternative" 74 | ], 75 | "size-limit": [ 76 | { 77 | "name": "Mande", 78 | "path": "dist/mande.prod.js", 79 | "import": "{ mande }" 80 | } 81 | ], 82 | "license": "MIT", 83 | "devDependencies": { 84 | "@microsoft/api-extractor": "^7.52.8", 85 | "@nuxt/types": "^2.18.1", 86 | "@rollup/plugin-alias": "^5.1.1", 87 | "@rollup/plugin-commonjs": "^28.0.3", 88 | "@rollup/plugin-node-resolve": "^16.0.1", 89 | "@rollup/plugin-replace": "^6.0.2", 90 | "@rollup/plugin-terser": "^0.4.4", 91 | "@size-limit/preset-small-lib": "^11.2.0", 92 | "@vitest/coverage-v8": "^3.1.4", 93 | "camelcase": "^8.0.0", 94 | "conventional-changelog-cli": "^5.0.0", 95 | "happy-dom": "^17.4.7", 96 | "prettier": "^3.5.3", 97 | "rimraf": "^6.0.1", 98 | "rollup": "^4.41.1", 99 | "rollup-plugin-typescript2": "^0.36.0", 100 | "size-limit": "^11.2.0", 101 | "typedoc": "^0.28.4", 102 | "typescript": "^5.8.3", 103 | "vitest": "^3.1.4" 104 | }, 105 | "repository": { 106 | "type": "git", 107 | "url": "git+https://github.com/posva/mande.git" 108 | }, 109 | "bugs": { 110 | "url": "https://github.com/posva/mande/issues" 111 | }, 112 | "homepage": "https://github.com/posva/mande#readme" 113 | } 114 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - nuxt-module 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>posva/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import replace from '@rollup/plugin-replace' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import ts from 'rollup-plugin-typescript2' 5 | import alias from '@rollup/plugin-alias' 6 | import terser from '@rollup/plugin-terser' 7 | import path from 'path' 8 | import { rimraf } from 'rimraf' 9 | import camelCase from 'camelcase' 10 | import pkg from './package.json' with { type: 'json' } 11 | 12 | const cwd = process.cwd() 13 | 14 | rimraf.sync(path.join(cwd, './dist')) 15 | 16 | const banner = `/*! 17 | * ${pkg.name} v${pkg.version} 18 | * (c) ${new Date().getFullYear()} Eduardo San Martin Morote 19 | * @license MIT 20 | */` 21 | 22 | const exportName = camelCase(pkg.name, { pascalCase: true }) 23 | 24 | function createEntry( 25 | { 26 | format, // Rollup format (iife, umd, cjs, es) 27 | external, // Rollup external option 28 | input = 'src/index.ts', // entry point 29 | env = 'development', // NODE_ENV variable 30 | minify = false, 31 | isBrowser = false, // produce a browser module version or not 32 | } = { 33 | input: 'src/index.ts', 34 | env: 'development', 35 | minify: false, 36 | isBrowser: false, 37 | } 38 | ) { 39 | // force production mode when minifying 40 | if (minify) env = 'production' 41 | 42 | const config = { 43 | input, 44 | plugins: [ 45 | replace({ 46 | preventAssignment: true, 47 | values: { 48 | __VERSION__: pkg.version, 49 | 'process.env.NODE_ENV': `'${env}'`, 50 | }, 51 | }), 52 | alias({ 53 | // resolve: ['.ts', '.js'], 54 | // entries: [{ find: 'firebase', replacement: path.join(__dirname, './stub') }], 55 | }), 56 | ], 57 | output: { 58 | banner, 59 | file: `dist/${pkg.name}.UNKNOWN.js`, 60 | format, 61 | }, 62 | } 63 | 64 | if (format === 'iife') { 65 | config.output.file = pkg.unpkg 66 | config.output.name = exportName 67 | } else if (format === 'es') { 68 | config.output.file = pkg.module 69 | } else if (format === 'cjs') { 70 | config.output.file = pkg.module.replace('js', 'cjs') 71 | } 72 | 73 | if (!external) { 74 | config.plugins.push(resolve(), commonjs()) 75 | } else { 76 | config.external = external 77 | } 78 | 79 | config.plugins.push( 80 | ts({ 81 | // only check once, during the es version with browser (it includes external libs) 82 | check: !tsChecked, 83 | tsconfigOverride: { 84 | exclude: ['__tests__'], 85 | compilerOptions: { 86 | // same for d.ts files 87 | declaration: !tsChecked, 88 | target: format === 'es' && !isBrowser ? 'esnext' : 'es2015', 89 | }, 90 | }, 91 | }) 92 | ) 93 | 94 | tsChecked = true 95 | 96 | if (minify) { 97 | config.plugins.push( 98 | terser({ 99 | module: format === 'es', 100 | }) 101 | ) 102 | config.output.file = config.output.file.replace(/\.([mc]?js)$/i, '.prod.$1') 103 | } 104 | 105 | return config 106 | } 107 | 108 | let tsChecked = false 109 | 110 | const builds = [ 111 | createEntry({ format: 'cjs' }), 112 | createEntry({ format: 'es' }), 113 | createEntry({ format: 'cjs', minify: true }), 114 | ] 115 | 116 | if (pkg.unpkg) 117 | builds.push( 118 | createEntry({ format: 'iife' }), 119 | createEntry({ format: 'iife', minify: true }), 120 | createEntry({ format: 'es', minify: true }) 121 | ) 122 | 123 | export default builds 124 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | echo "Current version:" $(grep version package.json | sed -E 's/^.*"(4[^"]+)".*$/\1/') 3 | echo "Enter version e.g. 0.1.0: " 4 | read VERSION 5 | 6 | read -p "Releasing v$VERSION - are you sure? (y/n)" -n 1 -r 7 | echo # (optional) move to a new line 8 | if [[ $REPLY =~ ^[Yy]$ ]]; then 9 | echo "Releasing v$VERSION ..." 10 | 11 | yarn run build 12 | yarn run build:dts 13 | 14 | # generate the version so that the changelog can be generated too 15 | yarn version --no-git-tag-version --no-commit-hooks --new-version $VERSION 16 | 17 | # changelog 18 | yarn run changelog 19 | yarn prettier --write CHANGELOG.md 20 | echo "Please check the git history and the changelog and press enter" 21 | read OKAY 22 | 23 | # commit and tag 24 | git add CHANGELOG.md package.json 25 | git commit -m "release: v$VERSION" 26 | git tag "v$VERSION" 27 | 28 | # commit 29 | yarn publish --new-version "$VERSION" --no-commit-hooks --no-git-tag-version 30 | 31 | # publish 32 | git push origin refs/tags/v$VERSION 33 | git push 34 | fi 35 | -------------------------------------------------------------------------------- /size-check/index.js: -------------------------------------------------------------------------------- 1 | import { mande } from '../dist/mande.mjs' 2 | 3 | mande('/api/') 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | // types 3 | MandeError, 4 | MandeInstance, 5 | MandeResponse, 6 | Options, 7 | OptionsRaw, 8 | ResponseAsTypes, 9 | 10 | // internal types 11 | _OptionsDefaults, 12 | _OptionsMerged, 13 | 14 | // values 15 | defaults, 16 | mande, 17 | nuxtWrap, 18 | } from './mande' 19 | -------------------------------------------------------------------------------- /src/interceptors.ts: -------------------------------------------------------------------------------- 1 | import { MandeError, MandeInstance, _OptionsMerged } from './mande' 2 | 3 | /** 4 | * Mande instance with interceptors. 5 | */ 6 | export interface MandeInstanceWithInterceptors extends MandeInstance { 7 | onRequest(_context: { 8 | url: string 9 | options: _OptionsMerged 10 | }): _MaybePromise 11 | 12 | onResponse(_context: { 13 | url: string 14 | options: _OptionsMerged 15 | response: Response 16 | }): unknown 17 | 18 | onError(_context: { 19 | url: string 20 | options: _OptionsMerged 21 | error: MandeError 22 | }): _MaybePromise 23 | } 24 | 25 | /** 26 | * Internal type to define a function that may return a Promise. 27 | * @internal 28 | */ 29 | type _MaybePromise = T | PromiseLike 30 | 31 | /** 32 | * NOTES: We currently pass a string and RequestInit options to fetch requests rather than doing new Request(url, options). Not sure if it's worth refactoring because Request is immutable (cannot change the url or body) and silently fails when changing properties 33 | */ 34 | 35 | export function withInterceptors( 36 | mande: MandeInstance 37 | ): MandeInstanceWithInterceptors { 38 | const mandeWithInterceptors = mande as MandeInstanceWithInterceptors 39 | 40 | mandeWithInterceptors.onRequest = () => {} 41 | mandeWithInterceptors.onResponse = () => {} 42 | mandeWithInterceptors.onError = () => {} 43 | 44 | // TODO: override 45 | 46 | return mandeWithInterceptors 47 | } 48 | -------------------------------------------------------------------------------- /src/mande.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Allowed options for a request. Extends native `RequestInit`. 3 | */ 4 | export interface Options 5 | extends RequestInit { 6 | /** 7 | * Optional query object. Does not support arrays. Will get stringified 8 | */ 9 | query?: any 10 | 11 | /** 12 | * What kind of response is expected. Defaults to `json`. `response` will 13 | * return the raw response from `fetch`. 14 | */ 15 | responseAs?: ResponseAs 16 | 17 | /** 18 | * Headers sent alongside the request 19 | */ 20 | headers?: Record 21 | 22 | /** 23 | * Optional function to stringify the body of the request for POST and PUT requests. Defaults to `JSON.stringify`. 24 | */ 25 | stringify?: (data: unknown) => string 26 | } 27 | 28 | export type ResponseAsTypes = 'json' | 'text' | 'response' 29 | 30 | export interface OptionsRaw 31 | extends Omit, 'headers' | 'signal'> { 32 | /** 33 | * Headers sent alongside the request. Set any header to null to remove it. 34 | */ 35 | headers?: Record 36 | 37 | /** 38 | * AbortSignal can only be passed to requests, not to a mande instance 39 | * because it can only be used once. 40 | */ 41 | signal?: never 42 | } 43 | 44 | /** 45 | * Extended Error with the raw `Response` object. 46 | */ 47 | export interface MandeError extends Error { 48 | body: T 49 | response: Response 50 | } 51 | 52 | /** 53 | * Checks if an object is a MandeError 54 | * @param err - error to check 55 | */ 56 | export function isMandeError(err: any): err is MandeError { 57 | return err instanceof Error && 'response' in err 58 | } 59 | 60 | /** 61 | * Response type of a Mande request based on `responseAs` option. . If `responseAs` is set to `response`, it will return 62 | the raw `Response` object. 63 | */ 64 | export type MandeResponse< 65 | T = unknown, 66 | ResponseType extends ResponseAsTypes = 'json', 67 | > = Promise< 68 | ResponseType extends 'response' 69 | ? Response 70 | : ResponseType extends 'text' 71 | ? string 72 | : T 73 | > 74 | 75 | /** 76 | * Object returned by {@link mande} 77 | */ 78 | export interface MandeInstance { 79 | /** 80 | * Writable options. 81 | */ 82 | options: Required> & Omit 83 | 84 | /** 85 | * Sends a GET request to the base url. This is equivalent to calling get with an empty string. 86 | * 87 | * @example 88 | * ```js 89 | * const userList = await users.get() 90 | * ``` 91 | * @param options - optional {@link Options} 92 | */ 93 | get( 94 | options?: Options 95 | ): MandeResponse 96 | 97 | /** 98 | * Sends a GET request to the given url. 99 | * 100 | * @example 101 | * ```js 102 | * const userProfile = await users.get('2') 103 | * ``` 104 | * @param url - optional relative url to send the request to 105 | * @param options - optional {@link Options} 106 | */ 107 | get( 108 | url: string | number, 109 | options?: Options 110 | ): MandeResponse 111 | 112 | /** 113 | * Sends a POST request to the base url. This is equivalent to calling post with an empty string. 114 | * 115 | * @example 116 | * ```js 117 | * const createdUser = await users.post({ name: 'Eduardo' }) 118 | * ``` 119 | * @param data - optional body of the request 120 | * @param options - optional {@link Options} 121 | */ 122 | post( 123 | data?: any, 124 | options?: Options 125 | ): MandeResponse 126 | /** 127 | * Sends a POST request to the given url. 128 | * 129 | * @example 130 | * ```js 131 | * const createdUser = await users.post('', { name: 'Eduardo' }) 132 | * ``` 133 | * @param url - relative url to send the request to 134 | * @param data - optional body of the request 135 | * @param options - optional {@link Options} 136 | */ 137 | post( 138 | url: string | number, 139 | data?: any, 140 | options?: Options 141 | ): MandeResponse 142 | 143 | /** 144 | * Sends a PUT request to the base url. This is equivalent to calling put with an empty string. 145 | * 146 | * @example 147 | * ```js 148 | * users.put({ name: 'Eduardo' }) 149 | * ``` 150 | * @param data - optional body of the request 151 | * @param options - optional {@link Options} 152 | */ 153 | put( 154 | data?: any, 155 | options?: Options 156 | ): MandeResponse 157 | /** 158 | * Sends a PUT request to the given url. 159 | * 160 | * @example 161 | * ```js 162 | * users.put('2', { name: 'Eduardo' }) 163 | * ``` 164 | * @param url - relative url to send the request to 165 | * @param data - optional body of the request 166 | * @param options - optional {@link Options} 167 | */ 168 | put( 169 | url: string | number, 170 | data?: any, 171 | options?: Options 172 | ): MandeResponse 173 | 174 | /** 175 | * Sends a PATCH request to the base url. This is equivalent to calling patch with an empty string. 176 | * 177 | * @example 178 | * ```js 179 | * users.patch({ name: 'Eduardo' }) 180 | * ``` 181 | * @param data - optional body of the request 182 | * @param options - optional {@link Options} 183 | */ 184 | patch( 185 | data?: any, 186 | options?: Options 187 | ): MandeResponse 188 | /** 189 | * Sends a PATCH request to the given url. 190 | * 191 | * @example 192 | * ```js 193 | * users.patch('2', { name: 'Eduardo' }) 194 | * ``` 195 | */ 196 | patch( 197 | url: string | number, 198 | data?: any, 199 | options?: Options 200 | ): MandeResponse 201 | 202 | /** 203 | * Sends a DELETE request to the base url. This is equivalent to calling delete with an empty string. 204 | * 205 | * @example 206 | * ```js 207 | * users.delete() 208 | * ``` 209 | * @param options - optional {@link Options} 210 | */ 211 | delete( 212 | options?: Options 213 | ): MandeResponse 214 | /** 215 | * Sends a DELETE request to the given url. 216 | * 217 | * @example 218 | * ```js 219 | * users.delete('2') 220 | * ``` 221 | * @param url - relative url to send the request to 222 | * @param options - optional {@link Options} 223 | */ 224 | delete( 225 | url: string | number, 226 | options?: Options 227 | ): MandeResponse 228 | } 229 | 230 | function stringifyQuery(query: any): string { 231 | let searchParams = Object.keys(query) 232 | .map((k) => [k, query[k]].map(encodeURIComponent).join('=')) 233 | .join('&') 234 | return searchParams ? '?' + searchParams : '' 235 | } 236 | 237 | let trailingSlashRE = /\/+$/ 238 | let leadingSlashRE = /^\/+/ 239 | 240 | function joinURL(base: string, url: string): string { 241 | return ( 242 | base + 243 | (url && 244 | (base.endsWith('/') 245 | ? url.replace(leadingSlashRE, '') 246 | : url.startsWith('/') 247 | ? url 248 | : '/' + url)) 249 | ) 250 | } 251 | 252 | function removeNullishValues( 253 | headers: Exclude 254 | ): Exclude { 255 | return Object.keys(headers).reduce( 256 | (newHeaders, headerName) => { 257 | if (headers[headerName] != null) { 258 | // @ts-ignore 259 | newHeaders[headerName] = headers[headerName] 260 | } 261 | return newHeaders 262 | }, 263 | {} as Exclude 264 | ) 265 | } 266 | 267 | /** 268 | * Used internally for merged options. 269 | * @internal 270 | */ 271 | export type _OptionsDefaults = Options & 272 | Pick, 'headers' | 'responseAs' | 'stringify'> 273 | /** 274 | * Global default options as {@link Options} that are applied to **all** mande 275 | * instances. Always contain an initialized `headers` property with the default 276 | * headers: 277 | * - Accept: 'application/json' 278 | * - 'Content-Type': 'application/json' 279 | */ 280 | export const defaults: _OptionsDefaults = { 281 | responseAs: 'json', 282 | headers: { 283 | Accept: 'application/json', 284 | // NOTE: instead of passing json here, we pass it when creating the request to automatically handle FormData 285 | // 'Content-Type': data instanceof FormData ? null : 'application/json', 286 | }, 287 | stringify: JSON.stringify, 288 | } 289 | 290 | /** 291 | * Used internally for merged options 292 | * 293 | * @internal 294 | */ 295 | export type _OptionsMerged = _OptionsDefaults & 296 | Pick, 'method'> 297 | 298 | /** 299 | * Create a Mande instance 300 | * 301 | * @example 302 | * ```js 303 | * const users = mande('/api/users') 304 | * users.get('2').then(user => { 305 | * // do something 306 | * }) 307 | * ``` 308 | * @param baseURL - absolute url 309 | * @param passedInstanceOptions - optional options that will be applied to every 310 | * other request for this instance 311 | */ 312 | export function mande( 313 | baseURL: string, 314 | passedInstanceOptions: OptionsRaw = {}, 315 | fetchPolyfill?: Window['fetch'] 316 | ): MandeInstance { 317 | function _fetch( 318 | method: string, 319 | // url can be any method, data for POST/PUT/PATCH, and options for all (without url or data) 320 | urlOrDataOrOptions?: string | number | Options | any, 321 | // data for POST/PUT/PATCH, and options for all (without url or data) 322 | dataOrOptions?: Options | any, 323 | localOptions: Options = {} 324 | ) { 325 | let url: string | number 326 | let data: any 327 | // at least the URL was omitted, localOptions wasn't passed so we can safely override it 328 | // get(options) or put(data, options) or put(options) 329 | if (typeof urlOrDataOrOptions === 'object') { 330 | url = '' 331 | // if urlOrDataOrOptions is an object, it's either options or data 332 | // if dataOrOptions was passed, urlOrDataOrOptions is data 333 | localOptions = dataOrOptions || urlOrDataOrOptions || {} 334 | // if it's a POST/PUT/PATCH, dataOrOptions is data 335 | // if it's option, we will set data to options but it will be ignored later 336 | data = urlOrDataOrOptions 337 | } else { 338 | // get(url) or get(url, options) or put(url, data) or put(url, data, options) 339 | url = urlOrDataOrOptions 340 | data = dataOrOptions 341 | } 342 | 343 | let mergedOptions: _OptionsMerged = { 344 | ...defaults, 345 | ...instanceOptions, 346 | method, 347 | ...localOptions, 348 | // we need to ditch nullish headers 349 | headers: removeNullishValues({ 350 | // let the browser automatically set the content-type with FormData 351 | 'Content-Type': data instanceof FormData ? null : 'application/json', 352 | ...defaults.headers, 353 | ...instanceOptions.headers, 354 | ...localOptions.headers, 355 | }), 356 | } 357 | 358 | let query = { 359 | ...defaults.query, 360 | ...instanceOptions.query, 361 | ...localOptions.query, 362 | } 363 | 364 | let { responseAs } = mergedOptions 365 | 366 | url = joinURL(baseURL, typeof url === 'number' ? '' + url : url || '') 367 | 368 | // TODO: warn about multiple queries provided not supported 369 | // if (__DEV__ && query && urlInstance.search) 370 | 371 | // TODO: use URL and URLSearchParams 372 | url += stringifyQuery(query) 373 | 374 | // only stringify body if it's a POST/PUT/PATCH, otherwise it could be the options object 375 | // it's not used by GET/DELETE but it would also be wasteful 376 | if (method[0] === 'P' && data && !mergedOptions.body) { 377 | mergedOptions.body = 378 | data instanceof FormData ? data : mergedOptions.stringify(data) 379 | } 380 | 381 | // we check the localFetch here to account for global fetch polyfills and msw in tests 382 | const localFetch = typeof fetch != 'undefined' ? fetch : fetchPolyfill! 383 | 384 | if (!localFetch) { 385 | throw new Error( 386 | 'No fetch function exists. Make sure to include a polyfill on Node.js.' 387 | ) 388 | } 389 | 390 | return localFetch(url, mergedOptions) 391 | .then((response) => 392 | // This is to get the response directly in the next then 393 | Promise.all([ 394 | response, 395 | responseAs === 'response' 396 | ? response 397 | : // TODO: propagate error data to MandeError 398 | response[responseAs]().catch(() => null), 399 | ]) 400 | ) 401 | .then(([response, dataOrError]) => { 402 | if (response.status >= 200 && response.status < 300) { 403 | // data is a raw response when responseAs is response 404 | return responseAs !== 'response' && response.status == 204 405 | ? null 406 | : dataOrError 407 | } 408 | // Has better browser support and is way smaller than `class MandeError extends Error` 409 | let err = new Error(response.statusText) as MandeError 410 | err.response = response 411 | err.body = dataOrError 412 | throw err 413 | }) 414 | } 415 | 416 | const instanceOptions: MandeInstance['options'] = { 417 | query: {}, 418 | headers: {}, 419 | ...passedInstanceOptions, 420 | } 421 | 422 | return { 423 | options: instanceOptions, 424 | post: _fetch.bind(null, 'POST'), 425 | put: _fetch.bind(null, 'PUT'), 426 | patch: _fetch.bind(null, 'PATCH'), 427 | 428 | // these two have no body 429 | get: (url?: string | number | Options, options?: Options) => 430 | _fetch('GET', url, null, options), 431 | delete: (url?: string | number | Options, options?: Options) => 432 | _fetch('DELETE', url, null, options), 433 | } 434 | } 435 | 436 | /** 437 | * Creates an Nuxt 2 SSR compatible function that automatically proxies cookies to requests and works transparently on 438 | * the server and client (it still requires a fetch polyfill on Node). Note this is only needed if you need to proxy 439 | * cookies to the server. 440 | * 441 | * @example 442 | * ```js 443 | * import { mande, nuxtWrap } from 'mande' 444 | * 445 | * const fetchPolyfill = process.server ? require('node-fetch') : fetch 446 | * const users = mande(BASE_URL + '/api/users', {}, fetchPolyfill) 447 | * 448 | * export const getUserById = nuxtWrap(users, (api, id: string) => api.get(id)) 449 | * ``` 450 | * 451 | * @param api - Mande instance to wrap 452 | * @param fn - function to be wrapped 453 | */ 454 | export function nuxtWrap< 455 | M extends MandeInstance, 456 | F extends (api: M, ...args: any[]) => any, 457 | >(api: M, fn: F): (...args: Parameters) => ReturnType { 458 | // args for the api call + 1 because of api parameter 459 | const argsAmount = fn.length 460 | 461 | const wrappedCall: (...args: Parameters) => ReturnType = 462 | function _wrappedCall() { 463 | let apiInstance: M = api 464 | let args = Array.from(arguments) as Parameters 465 | // call from nuxt server with a function to augment the api instance 466 | if (arguments.length === argsAmount) { 467 | apiInstance = { ...api } 468 | 469 | // remove the first argument 470 | const [augmentApiInstance] = args.splice(0, 1) as [(api: M) => void] 471 | 472 | // let the caller augment the instance 473 | augmentApiInstance(apiInstance) 474 | } 475 | 476 | return fn.call(null, apiInstance, ...args) 477 | } 478 | 479 | return wrappedCall 480 | } 481 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "noEmit": true, 6 | "strict": true, 7 | "composite": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "Bundler", 10 | "skipLibCheck": true, 11 | "rootDir": ".", 12 | "baseUrl": "." 13 | }, 14 | "include": [ 15 | "src" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {Partial} */ 4 | const config = { 5 | name: 'Mande', 6 | // excludeInternal: true, 7 | out: 'docs-api', 8 | entryPoints: ['src/index.ts'], 9 | exclude: ['**/*.spec.ts'], 10 | } 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'happy-dom', 6 | typecheck: { 7 | enabled: true, 8 | }, 9 | coverage: { 10 | provider: 'v8', 11 | reporter: ['text', 'lcov'], 12 | include: ['src'], 13 | exclude: [ 14 | 'src/index.ts', 15 | 'src/**/*.test-d.ts', 16 | // TODO: remove once implemented 17 | 'src/interceptors.ts', 18 | ], 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /vitest.workspace.json: -------------------------------------------------------------------------------- 1 | ["./vitest.config.ts", "./nuxt-module/vitest.config.ts"] 2 | --------------------------------------------------------------------------------