# ono-fetch

English | [简体中文](./README.zh-CN.md)

A zero-dependency isomorphic HTTP client: one API for the browser and Node.js, built on the native `fetch`. Ships as a single ESM file — 5.4 KB minified / 2.5 KB gzipped.

```bash
npm i ono-fetch
```

ESM only (there is no `require` branch in `exports`) — from CommonJS, use a dynamic `import()`. `engines`: Node >= 18.

## Quick start

```ts
import { createOnoFetch } from 'ono-fetch'

const api = createOnoFetch({
  baseURL: '/api',
  timeout: 10_000,
  headers: { 'client-channel': 'web' }
})

type User = { id: number; name: string }

const { data, status } = await api.get<User>('/users/1')
const created = await api.post<User>('/users', { name: 'ono' })
if (status >= 400) throw new Error('failed to load')
```

The type of `data` is whatever you declare — it is never validated at runtime. Pairing `returnType: 'text'` with `api.get<User>` won't error; it just hands you a string.

## Two ways to call

```ts
api.get(url, options?)                    // five sugar methods
api.delete(url, options?)
api.post(url, body, options?)             // body is a positional argument
api.put(url, body, options?)
api.patch(url, body, options?)

api(url, options?)                        // call style: method / body both live in options
api.buildURL(url, { params }?)            // builds the URL only, sends nothing (sync, returns string)
api.extend(config?)                       // derives a new instance, this one stays untouched
```

- The sugar methods' `options` does **not** accept `method` or `body` — writing one is a compile error, rather than "compiles, then gets silently overwritten at runtime".
- Only those five verbs have sugar. They express everyday ergonomics; they do not define "which verbs this library supports". Everything else goes through the call style:

  ```ts
  await api('/users/1', { method: 'DELETE' })
  await api('/probe', { method: 'HEAD' })
  await api('/files/a.txt', { method: 'COPY', body: { dest: '/files/b.txt' } })
  ```

- `method` accepts any legal HTTP token (`OnoFetchMethod`), so private-protocol verbs like `MGET` and WebDAV's `PROPFIND` can be written directly. It is case-insensitive: legal tokens are upper-cased at the boundary (`{ method: 'head' }` is equivalent to `'HEAD'`), but whether the peer accepts it is still up to its own method table.
- The cost: `OnoFetchMethod` includes `string`, so a `switch` over it gets no exhaustiveness checking.

## Instance config `createOnoFetch(config?)`

| Field              | Default  | Notes                                                                                                                                                               |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseURL`          | `''`     | Slashes at the join are normalized (`'http://a/v1/'` + `'/users'` never yields `//`); skipped when `url` is absolute or protocol-relative (`//h/x`)                 |
| `timeout`          | `0`      | Milliseconds; `0` = no timeout                                                                                                                                      |
| `headers`          | —        | Instance-level request headers, merged key by key with the request level; on a collision the request wins (case-insensitive)                                        |
| `returnType`       | `'json'` | How the response is parsed; overridable per request                                                                                                                 |
| `paramsSerializer` | —        | Your own query-string builder; overridable per request (see Query parameters)                                                                                       |
| `jsonReviver`      | —        | Passed straight to `JSON.parse`; overridable per request                                                                                                            |
| `maxResponseSize`  | `0`      | Response body byte cap; `0` = unlimited. Overridable per request — writing `0` there means "no cap for this one"                                                    |
| `fetchImpl`        | —        | Your own transport — the function that actually makes the request, in place of `globalThis.fetch`. **Instance level only** (see Replacing the transport)            |
| `onRequest`        | `[]`     | Array of request hooks; change `headers` / `body` right before the request goes out (see Interceptors). **Instance level only**, and the array is fixed at creation |
| `onResponse`       | `[]`     | Array of response hooks; take the whole envelope, hand back a new one (this is where unwrapping `{ code, data, message }` lives). Same two restrictions             |
| `onError`          | `[]`     | Array of error hooks; the one place every rejection passes through — failure reporting / telemetry (see Interceptors). Same two restrictions                        |

Any field that also appears in the request options below is an override: writing `undefined` (not writing it at all) keeps the instance level, anything else is the request's own. `returnType` is the one to watch — its `null` is a real value (hand back the native `Response`), so only `undefined` means "not written". `onRequest` / `onResponse` / `onError` are deliberately **not** in that per-request table — the reason is in the Interceptors section below. `fetchImpl` is instance-only for the same reason: a transport is a property of an instance, not of one call.

### Deriving a new instance `api.extend(config?)`

`extend` builds a **new, complete instance** out of this one's resolved config and leaves the original untouched, so the two live side by side:

```ts
const base = createOnoFetch({ baseURL: '/api', headers: { 'x-app': 'web' } })
const admin = base.extend({ headers: { 'x-app': 'admin' } })

const config = { baseURL: '/api', onRequest: [addToken] }
// one extra hook on a derived instance, from the config you already hold
const traced = createOnoFetch(config).extend({
  onRequest: [...config.onRequest, addTraceId]
})
```

- Fields you don't write are inherited (that includes `baseURL` and the hook arrays). Writing `undefined` counts as _not_ writing; `null` and `0` count as written — `returnType: null` and `timeout: 0` really replace what would have been inherited.
- `headers` is replaced as a whole, not merged key by key. The merge the library does is between instance level and request level, and that one is unchanged.
- The three hook arrays **overwrite** rather than append: `onRequest: [a]` gives the derived instance exactly one hook. There is no `[...base.onRequest, a]` shortcut reading off the instance, because the library hands back no config object (`api.defaults` does not exist) — you keep the array you passed in.
- The derived instance is a normal instance: it has the five sugars, `buildURL`, and its own `extend`.

## Request options

| Field              | Notes                                                                                             |
| ------------------ | ------------------------------------------------------------------------------------------------- |
| `params`           | Query parameters (see below); unrelated to `body`                                                 |
| `paramsSerializer` | Overrides the instance-level query serializer                                                     |
| `headers`          | Request-level headers; any of the three `HeadersInit` forms (object / `Headers` / array of pairs) |
| `timeout`          | Overrides the instance level                                                                      |
| `returnType`       | Overrides the instance level                                                                      |
| `jsonReviver`      | Overrides the instance level                                                                      |
| `maxResponseSize`  | Overrides the instance level                                                                      |
| `signal`           | `AbortSignal` (`null` is the same as not passing one)                                             |
| `onProgress`       | Download progress, `(progress, loaded, total) => void` (see Progress)                             |
| `method` / `body`  | Present in **the call style only**                                                                |

Any other field is passed straight through to the underlying `fetch()` (`credentials`, `mode`, `cache`, ...). The library's own fields — `params` / `paramsSerializer` / `body` / `onProgress` / `jsonReviver` / `maxResponseSize` / `defaultHeaders` / `fetchImpl` — never leak into the object handed to `fetch()`.

## Return value

```ts
{
  ;(url, headers, status, statusText, data)
}
```

- `headers` is a `Record<string, string>` with all keys lower-cased; values containing a colon (something like `Date: a: b`) don't get the whole line dropped.
- For responses with no body (e.g. `HEAD`), `data` is always `null` and parsing via `returnType` is **skipped**.
- **Non-2xx resolves as usual; nothing is thrown.** This library rejects only on cancellation / timeout / network failure / an oversized response body / an unserializable `params` value — check the status code yourself. Those rejections are exactly what `onError` sees (see below).
- With `onResponse` installed, this whole envelope is whatever the hook returns (see below).

## Interceptors `onRequest` / `onResponse` / `onError`

All three are **instance-level arrays** with no per-request counterpart — a single request that needs special handling can compute its own headers at the call site, or catch its own error, which is not a new capability worth an API.

```ts
import type { OnoFetchRequestContext, OnoFetchReturnData } from 'ono-fetch'

const config = {
  baseURL: '/api',
  // read the token at the last possible moment, before the request goes out
  onRequest: [
    async (ctx: OnoFetchRequestContext) => {
      const headers = new Headers(ctx.headers)
      headers.set('authorization', `Bearer ${await getToken()}`)
      return { headers }
    }
  ],
  // hand back only what the business envelope wraps
  onResponse: [
    (res: OnoFetchReturnData) => {
      const body = res.data as { code: number; data: unknown; message: string }
      if (body.code !== 0) throw new Error(body.message)
      return { ...res, data: body.data }
    }
  ],
  // the one place every rejection passes through, whichever entry point it came from
  onError: [
    (error: unknown) => {
      reportToSentry(error)
    }
  ]
}

const api = createOnoFetch(config)
// the call site is unchanged; data is already the User
const { data } = await api.get<User>('/users/1')
```

Seven rules define the shape of `onRequest` / `onResponse` (the third hook, `onError`, has its own subsection below):

1. **A request hook receives `{ method, url, headers, body }`**, where `url` is the final URL — baseURL joined, query serialized. It **may only return `headers` / `body`** (writing `url` or `params` is a compile error) and returning `undefined` means "change nothing". Several hooks run in array order, each getting the previous one's output.
2. **Hooks cannot touch `url` or `params`.** The `params` convention belongs to `paramsSerializer`; letting hooks rebuild it creates a second source of truth. And "hooks never change the URL" is exactly what keeps `api.buildURL()` byte-for-byte identical to what really goes out — it is a synchronous pure function and could not await an async token hook anyway.
3. **The `headers` a hook returns land in the request-level slot**: they still beat `config.headers` on a matching key, and `content-type` is only inferred from the body when neither side wrote it. Merging still happens in that one place in the engine.
4. **Changing the body recomputes `content-type`**: swap an object for a string in a hook and the request goes out as `text/plain`. The `GET` / `HEAD` / `DELETE` body-dropping rule applies to the hook's output too.
5. **A response hook receives the whole envelope** (`url` / `headers` / `status` / `statusText` / `data`) and whatever it returns is what the caller gets — so checking both the HTTP status and the business `code` is possible. **It must return a value**: `res => { report(res) }`, with no `return res`, throws `OnoFetchError(code: 'UNKNOWN')` instead of silently handing out an `undefined` envelope. For a purely observational hook, write `return res`.
6. **Errors thrown by a hook pass through untouched**, unwrapped into `OnoFetchError` (same line as `paramsSerializer` / `jsonReviver`). Treating a non-zero business `code` as a failure is exactly this `throw` — a rule the caller declares in one place; the library still does not judge HTTP status and there is no `validateStatus` switch (see limitation 1).
7. **The arrays are copied at creation**: pushing into the original array later does not affect an instance that already exists. To get an instance with one extra hook, derive it: `api.extend({ onRequest: [...config.onRequest, extra] })` (see Deriving a new instance above) — arrays overwrite, so the full list comes from the config you passed in.

Under `returnType: null` the `onResponse` hooks **still run** (a hook sees `data` as the native `Response`); the rejection paths — cancellation / timeout / network failure — do not run `onResponse`, because those are not responses. They run `onError` instead.

**What this does not do: replay.** A hook can see and rewrite the response, but the library will not send the request a second time because a hook asked. "401 → refresh the token → replay" still has to be written by the caller, and there is no imperative registration (`use` / `eject`) to hang it on.

### Error hook `onError`

The point of this hook is **centralized failure reporting**. Wrapping a call site in `.catch(report)` is not the same thing: it only covers that one call site, while `onError` covers every rejection from every entry point — the call style and all five verb sugars alike.

```ts
const api = createOnoFetch({
  baseURL: '/api',
  onError: [
    async error => {
      await report({ error, when: Date.now() }) // observe; don't return anything
    },
    error =>
      // turn `TypeError: fetch failed` into a message this screen can show
      error instanceof TypeError
        ? new Error('网络不可用，请稍后重试')
        : undefined
  ]
})
```

- **It sees every rejection, including the ones that never went out**: `INVALID_QUERY`, a `paramsSerializer` that throws, an already-aborted `signal`. The hook chain sits outside the request, not inside it, so "the library never sent a byte" is still a failure it reports.
- **What it does not see**: non-2xx (that is a `resolve`, and the library does not judge HTTP status), and errors thrown inside an `onRequest` / `onResponse` body. Feeding those back in would make hooks report each other, and "the request failed" would become indistinguishable from "the caller's own hook code threw". One exception, deliberate: the `UNKNOWN` a response hook gets for forgetting to `return` is the library's own verdict, so it does come through.
- **The value it receives is whatever the promise rejects with** — no wrapping happens here. That means three families in one parameter: the underlying `TypeError: fetch failed`, the library's `OnoFetchError` (`ABORTED` / `TIMEOUT` / `TOO_LARGE` / `INVALID_QUERY`), and your own callbacks' errors. **The first and the third are not distinguishable**, so this hook is a good place to _report_ and a bad place to decide _whether to retry_: "the network is down" and "you wrote a bad header value" look identical here.
- **No context object.** You get the error and nothing else — no `{ method, url }`. Neither the error objects nor the instance carry the request config anywhere in this library, and a rejection has no status, no headers and no `url` to hand over. Log the URL from your own call site.
- **Returning `undefined` means "change nothing"** (the opposite rule from `onResponse`, because observing a failure is this hook's main use, not an afterthought). Any other value becomes the rejection value, and the input of the next hook in the array — so replacing `fetch failed` with your own error class is one hook's work.
- **It cannot recover a rejection into a resolve.** There is no envelope to synthesize at that point, so "swallow the error and return a default value" stays call-site `try/catch` work.
- **An `onError` hook that throws replaces the very error it was reporting** (same pass-through line as every other callback). Keep the reporting code inside its own `try/catch`.

## Response parsing `returnType`

| Value              | `data`                                                                                                                                               |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `'json'` (default) | The result of `JSON.parse`; on a parse failure the raw text is returned, no throw. `jsonReviver` takes part in the parse (see below)                 |
| `'text'`           | A string                                                                                                                                             |
| `'blob'`           | A `Blob`, whose `type` comes from the response content-type                                                                                          |
| `'arrayBuffer'`    | An `ArrayBuffer`                                                                                                                                     |
| `'formData'`       | A `FormData`, parsed per the response content-type; **rejects** when the response is not `multipart/form-data` / `application/x-www-form-urlencoded` |
| `null`             | No parsing — `data` is the native `Response` from `fetch()`, with not a single body byte read (see below)                                            |

### `jsonReviver` · joining the parse

```ts
api.get('/orders', {
  jsonReviver: (key, value) =>
    key === 'createdAt' && typeof value === 'string' ? new Date(value) : value
})
```

- Only applies to `returnType: 'json'`; one instance-level and one request-level copy, the request wins.
- **If the reviver itself throws, the promise rejects with your original error** — it is not swallowed by the "invalid JSON falls back to text" branch.
- When the response is not valid JSON the reviver is never called (syntax comes first), and the text fallback works as before.
- One thing it cannot do: recover a `Long` the backend wrote as a JSON number. By the time `JSON.parse` hands a value to the reviver it is already a lossy `double` (`1234567890123456789` → `1234567890123456800`). For that, read `returnType: 'text'` and parse yourself, or have the backend send a string.

Mind `blob.type`: the `Blob` spec lower-cases the entire `type`, so a multipart `boundary` parameter value gets mangled. To split multipart yourself, use `headers['content-type']`, not `blob.type`.

### `returnType: null` · handing back the native Response

Pass `null` when you want to consume the body yourself (streaming reads, custom parsing, `pipe`):

```ts
const { data: rep } = await api.get<Response>('/stream', { returnType: null })
if (rep.body) {
  for await (const chunk of rep.body) {
    // ...
  }
}
```

- The envelope is unchanged: `url` / `headers` / `status` / `statusText` are still provided and non-2xx still resolves; only `data` differs.
- Why `null` rather than `undefined`: omitting `returnType` already means "default `'json'`", and at runtime `undefined` and "not written" are the same thing.
- `timeout` in this mode stops at the response headers — the library never reads the body here, so it doesn't time it either. After resolve, `signal` stays attached to that response — same semantics as handing the signal straight to `fetch()`, so `abort()` interrupts your subsequent `rep.json()` / body reads.
- Neither `onProgress` nor `maxResponseSize` applies in this mode — the library doesn't touch the body, so it can neither count it nor decide for you when to stop reading.

## Query parameters `params`

```ts
api.get('/search', {
  params: { q: 'a b', ids: [1, 2], empty: null, skip: undefined }
})
// → /search?q=a+b&ids=1&ids=2&empty=
```

- `undefined` = the key is not sent at all; `null` = an empty value is sent (`?empty=`); arrays expand into repeated keys.
- Encoding follows `URLSearchParams` (space → `+`); no `ids[]=` brackets syntax.
- Values are encoded by the library, exactly **once** — nothing checks whether a value is already encoded, so do not pre-`encodeURIComponent` one, or `%2F` comes out as `%252F`. Need to own the encoding? `paramsSerializer` below is for that.
- Independent of `method` — it works for every verb; when `url` already carries a query, parameters are appended with `&`.
- Values may only be scalars or arrays of scalars. An out-of-band value (an object, a function, ...) cannot be written in the types, and when you bypass them (plain JS, `as any`) it **throws** `OnoFetchError(code: 'INVALID_QUERY')` instead of handing you `filter=%5Bobject%20Object%5D` — a URL that goes out fine and that no backend recognizes. Need such a value? That's what `paramsSerializer` is for.

### `paramsSerializer` · the whole convention is yours

`ids[]=1&ids[]=2` (PHP / Rails), `ids=1,2` (Spring's default), keys sorted for a signature — any single built-in convention is bound to fit only half the backends, so what is opened here is the entire serialization step:

```ts
const api = createOnoFetch({
  baseURL: '/api',
  paramsSerializer: params =>
    `ids=${(params.ids as (string | number)[]).join(',')}`
})
// a request-level paramsSerializer overrides the instance one
```

- Return the complete query string **without a `?`** (`a=1&b=2`); whether it is appended with `?` or `&` is decided by the library from the url. Returning an empty string means no query at all.
- It receives the whole `params` object and the library no longer validates anything for you — sorting, `encodeURIComponent` (not `+`), brackets, all of it is yours to write in those few lines.
- If the serializer throws, the promise rejects with your original error (it is not wrapped in `OnoFetchError`).
- Without `params` the serializer is not invoked (there's nothing to serialize).

## Building a URL `api.buildURL(url, options?)`

```ts
api.buildURL('/users/1', { params: { expand: 'orders' } })
// → 'https://api.example.com/v1/users/1?expand=orders'
```

It shares the very same `joinURL` / `withQuery` call as a real request, so the result is character-for-character the URL that would go out — use it for shareable links, cache keys, tracking keys or test assertions and it can't drift from the request.

- URL only, no headers: `method` / `body` / `headers` take no part in the join, and `options` has exactly the two fields that do (`params`, `paramsSerializer`) — anything else is a compile error.
- It is a pure function returning a `string` synchronously, so an out-of-band `params` value **throws** here rather than rejecting a promise.
- The result contains the full query: don't put a long-lived credential in `params` and then report or print this string.

## Request body

`api.post(url, body)` or the call style's `{ body }`. Content-Type is inferred from the shape of the value you pass:

| Input             | Inferred Content-Type                                                                      |
| ----------------- | ------------------------------------------------------------------------------------------ |
| Plain object      | `application/json;charset=utf-8` (`JSON.stringify`)                                        |
| Array             | `application/json;charset=utf-8` (`JSON.stringify`)                                        |
| `FormData`        | Not set — the browser generates the `boundary`; setting it manually breaks backend parsing |
| `URLSearchParams` | `application/x-www-form-urlencoded;charset=utf-8`                                          |
| `Blob`            | `blob.type`, or `application/octet-stream` when empty                                      |
| `string`          | `text/plain;charset=utf-8` (no guessing whether it is JSON)                                |

- A Content-Type you set explicitly is never overwritten by the inferred one (case-insensitive).
- `OnoFetchParams` is exactly this table (`Record<string, unknown> | unknown[] | FormData | URLSearchParams | string | Blob | undefined`) — the types and the runtime now match one for one. Bare primitives (`api.post(url, 1)`) are not in the types; there is a fallback branch that `String()`s anything at runtime, and it exists only for callers who bypassed the types.
- `GET` / `HEAD` / `DELETE` with a body silently drop it — see Known limitations.

## Cancellation, timeouts and errors

```ts
import { OnoFetchError } from 'ono-fetch'

const controller = new AbortController()
try {
  await api.get('/slow', { signal: controller.signal, timeout: 3_000 })
} catch (e) {
  if (e instanceof OnoFetchError) {
    // 'ABORTED' | 'TIMEOUT' | 'TOO_LARGE' | 'INVALID_QUERY' | 'UNKNOWN'
    if (e.code === 'TIMEOUT') console.log('timed out')
  }
}
```

- `OnoFetchError` subclasses `Error` with `name` set to `'OnoFetchError'`, so existing `catch (e)` blocks and message matching are unaffected.
- **Branch on `code`, never on the message text**: the historical timeout message is literally `'Request aborted'` — the same string as a manual cancellation. Use `code` to tell them apart. Published message text never changes.
- Passing an already-aborted `signal` rejects immediately with `ABORTED`, without a single request going out.
- When both `signal` and `timeout` are given, both take effect.
- **Both cover the whole promise, not just the headers.** The deadline is what the library hands to `AbortController.abort()`, so a peer that sends its headers promptly and then trickles the body is cut off too (`TIMEOUT`), and an `abort()` after the headers still interrupts the body read the library is doing itself (`ABORTED`). `returnType: null` is the one exception: there the body belongs to you, so `timeout` stops at the headers.
- Only failures the library itself adjudicates get wrapped: cancellation `ABORTED`, timeout `TIMEOUT`, oversized body `TOO_LARGE`, unserializable `params` `INVALID_QUERY`, a response hook that returned nothing `UNKNOWN`. Everything else comes out exactly as the underlying layer threw it — connection failure (server down, DNS dead) is a `TypeError: fetch failed` (details in `error.cause`), and so are errors thrown while parsing the response, errors your own `paramsSerializer` / `jsonReviver` / `onRequest` / `onResponse` / `onError` throw, and errors an injected `fetchImpl` throws. Wrapping those would break `e instanceof TypeError` in existing code, so this line is not being moved. `onError` sees this same unwrapped value.

## Response size cap `maxResponseSize`

```ts
const api = createOnoFetch({ baseURL: '/api', maxResponseSize: 5_000_000 })
// one request pardoned: 0 = no cap for this one
await api.get('/export', { maxResponseSize: 0 })
```

Going over the cap **kills the stream and rejects** `OnoFetchError(code: 'TOO_LARGE')` — you never get a truncated `data` that looks complete. It counts **decoded** bytes, so on a gzip response it can be larger than the response's `content-length`. Download direction only: how big a request body is allowed to be is a decision your own code is better placed to make, and the library does not second-guess `normalizeBody`.

## Progress

```ts
api.get('/large.json', {
  onProgress: (progress, loaded, total) => {
    if (progress === undefined) {
      // total unknown: show bytes read, don't draw a percentage bar
      setStatus(`downloaded ${loaded} bytes`)
      return
    }
    setPercent(progress)
  }
})
```

Called once per chunk, whether or not the response has a `content-length`. Three arguments: `progress` (a 0..1 ratio), `loaded` (decoded bytes read so far), `total` (the amount `content-length` declares).

- `progress` and `total` are `undefined` **together**: chunked responses carry no `content-length`, and with `content-encoding` (gzip and friends) that header counts compressed bytes, which are not comparable to the decompressed `loaded` — better to report no total than a ratio that runs past 1.
- `progress` is typed `number | undefined`, so a TS project that ignores the unknown case simply fails to compile. That is deliberate: a progress bar drawn from an invented number is worse than no bar.
- Download direction only. There is no upload progress event at the `fetch` layer, so `onUploadProgress` is not something this library can do.
- It never fires under `returnType: null` — the body is yours to read.

## Replacing the transport `fetchImpl`

```ts
const api = createOnoFetch({ baseURL: '/api', fetchImpl: myFetch })
```

This replaces exactly one call — `globalThis.fetch(input, init)`. Everything above it stays the library's: `baseURL` joining, query serialization, header merging, the `timeout` / `signal` wiring, the streaming body read, `maxResponseSize`, `returnType` parsing and the three hooks.

The reason it exists is a hole in the platform, not a missing feature: `fetch` (browser fetch, Node's undici) has **no per-request `agent` / `proxy` / `dispatcher` option**, so the library could accept a `proxy` field and then have nowhere to hand it to. Silent no-op options are worse than absent ones, so instead the transport itself is open:

```ts
// Node: this instance goes through a proxy (undici is yours to install, not ours)
import { fetch, ProxyAgent } from 'undici'

const dispatcher = new ProxyAgent('http://127.0.0.1:7890')
const api = createOnoFetch({
  fetchImpl: (input, init) => fetch(input, { ...init, dispatcher })
})
```

Two hard requirements, because the abort wiring and the envelope both go through this function:

- **It must honor `init.signal`.** The controller we pass there is the only thing `timeout` and your `signal` reach it through, so ignoring it makes cancellation impossible. When the signal fires, reject the way `fetch` does (`error.name === 'AbortError'`) — that name is what the library turns into `TIMEOUT` / `ABORTED`; any other rejection value comes out raw.
- **It must return a real `Response`.** `status` / `statusText` / `headers` / `url` and the body read all come off that object; anything else rejects with a `TypeError`.

- **Instance level only**: writing `fetchImpl` in the request options is a compile error, like the three hooks. One call on a different transport is `api.extend({ fetchImpl: other })` away.
- **Narrower than axios's `adapter`**: that one takes the whole config and returns a response, so it owns URL building and body handling too; this one sits underneath every step the library has already done, and its arguments are the final `(url, init)`.
- **Its errors pass through unwrapped**, and `onError` counts them in the same bucket as `TypeError: fetch failed` — which means an injected transport also injects its own error shapes into that hook.
- **It is a behavior swap, not a sandbox**: that function sees every header (including the token a hook just added) and every body, and decides what bytes come back. Only pass code you control.
- Under `returnType: null` the native `Response` the caller gets **is** your implementation's object, so whatever it does to `url` or `headers` shows up in the envelope as-is.

## Known limitations (behaviors kept on purpose)

1. Non-2xx does not throw.
2. When `GET` / `HEAD` / `DELETE` carry a request body, the body and the inferred `content-type` are silently dropped together (native `fetch` throws a `TypeError` outright for a `HEAD` with a body; we absorb that on its behalf). The spec allows `DELETE` to carry a body; opening that up is a behavioral change in its own right and hasn't been done.
3. Types only block object literals: `const o = { method: 'POST' }; api.get(url, o)` still compiles (TS's excess property check doesn't apply to non-fresh objects); at runtime the method name wins.
4. The three hooks are all there is: no imperative registration (`use` / `eject`), no request replay, no retries, no concurrency limits, no queue. And `onError` is a reporting outlet, not a retry policy — a network failure and a caller mistake can be indistinguishable there. The one extension point in the library is `fetchImpl`, and it swaps the transport call, nothing else.
5. `OnoFetchMethod` includes `string` and cannot be enumerated.
6. No consistency check between the generic `data` and `returnType`.
7. **isomorphic** means the API is the same and the code path is one — the library never branches per platform. It is not a claim that the browser's `fetch` and Node's (`undici`) behave identically; differences below the transport boundary belong to the platform, and this library neither hides them behind a shim nor promises to smooth them over. Installing `fetchImpl` doesn't weaken that: it is you picking a transport for one instance, not the library forking its code per platform.

## Trade-offs vs axios

**Pick ono-fetch** when you care about bundle size and zero dependencies; when browser and Node must share one API; when you prefer the single general path `api(url, { method })` over a config object.

**Pick axios** when you need imperative interceptor registration, request replay, the cancel-token ecosystem, upload progress, a direct CommonJS `require`, or transport config as declarative fields (`proxy`, `httpAgent`, ...) — axios has a Node adapter that really holds those, while `fetch` gives this library nowhere to put them, so here the whole transport is one injected function instead.

The key differences in behavior: axios throws on non-2xx by default, this library resolves; axios keeps `params` and `data` both inside the config object, while here `body` is a positional argument of `post/put/patch`; axios registers interceptors on the instance whenever it likes, while here the three hook arrays are fixed when you call `createOnoFetch` — and a request hook cannot rewrite the URL or `params`. Where axios reaches for `Object.assign`-style mutation of `api.defaults` and `api.interceptors`, this library hands you a new instance: `api.extend(config)`.

## License

MIT
