feat(fetch): plugin system + eslint/tsconfig migration
- Add fetch plugin API (definePlugin, plugins) with type-level option flow. - Migrate to eslint flat config and composite tsconfig.
This commit is contained in:
@@ -0,0 +1,376 @@
|
|||||||
|
# @robonen/fetch
|
||||||
|
|
||||||
|
A lightweight, type-safe `fetch` wrapper with interceptors, retry, timeout, and a composable plugin system — V8-optimized internals, zero runtime dependencies beyond the standard library.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { $fetch } from '@robonen/fetch';
|
||||||
|
|
||||||
|
const user = await $fetch<User>('https://api.example.com/users/1');
|
||||||
|
// ^? User — body is parsed and typed for you
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install @robonen/fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`globalThis.fetch` is great primitive plumbing, but every app re-implements the same layer on top of it: JSON parsing, throwing on `4xx`/`5xx`, base URLs, query strings, retries, timeouts, auth headers. `@robonen/fetch` is that layer — small, typed, and built so attaching features costs nothing on the hot path.
|
||||||
|
|
||||||
|
- **🎯 Type-safe** — response data, options, and plugin-contributed fields are all inferred.
|
||||||
|
- **📦 Smart bodies** — plain objects are JSON-serialized; `FormData`/`Blob`/streams pass through untouched.
|
||||||
|
- **🧠 Auto-parsing** — response decoded from `Content-Type`, or forced via `responseType`.
|
||||||
|
- **💥 Errors that throw** — non-2xx responses reject with a rich `FetchError`.
|
||||||
|
- **🔁 Retry & ⏱️ timeout** — built in, per-request configurable, sensible defaults.
|
||||||
|
- **🪝 Lifecycle hooks** — `onRequest` / `onResponse` / `onRequestError` / `onResponseError`.
|
||||||
|
- **🧩 Plugins** — composed once, typed, with onion-style `execute` middleware.
|
||||||
|
- **🌱 `create` / `extend`** — derive pre-configured instances that inherit defaults and plugins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { $fetch } from '@robonen/fetch';
|
||||||
|
|
||||||
|
// GET + automatic JSON parse
|
||||||
|
const todo = await $fetch<Todo>('https://api.example.com/todos/1');
|
||||||
|
|
||||||
|
// POST a plain object — serialized to JSON, content-type set for you
|
||||||
|
const created = await $fetch<Todo>('https://api.example.com/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { title: 'Ship it', done: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method shortcuts
|
||||||
|
await $fetch.get('https://api.example.com/todos');
|
||||||
|
await $fetch.delete('https://api.example.com/todos/1');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Type-safe responses
|
||||||
|
|
||||||
|
The first type parameter types the parsed body; the second drives the parsing mode.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface User { id: number; name: string }
|
||||||
|
|
||||||
|
const user = await $fetch<User>('/users/1'); // Promise<User>
|
||||||
|
const ids = await $fetch<number[]>('/users/ids'); // Promise<number[]>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method shortcuts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await $fetch.get<User>('/users/1');
|
||||||
|
await $fetch.post<User>('/users', { body: { name: 'Alice' } });
|
||||||
|
await $fetch.put('/users/1', { body: patch });
|
||||||
|
await $fetch.patch('/users/1', { body: partial });
|
||||||
|
await $fetch.delete('/users/1');
|
||||||
|
await $fetch.head('/users/1'); // returns the raw Response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base URL & query params
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const api = $fetch.create({ baseURL: 'https://api.example.com/v1' });
|
||||||
|
|
||||||
|
await api('/users'); // → https://api.example.com/v1/users
|
||||||
|
await api('/search', { query: { q: 'vue', page: 2 } });
|
||||||
|
// → ...?q=vue&page=2
|
||||||
|
```
|
||||||
|
|
||||||
|
`null` / `undefined` query values are dropped, existing query strings are preserved, and the query is inserted **before** any `#fragment`.
|
||||||
|
|
||||||
|
### Automatic body serialization
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Plain object → JSON (content-type: application/json + accept: application/json)
|
||||||
|
await $fetch.post('/users', { body: { name: 'Alice' } });
|
||||||
|
|
||||||
|
// content-type: application/x-www-form-urlencoded → form-encoded automatically
|
||||||
|
await $fetch.post('/login', {
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: { user: 'a', pass: 'b' }, // → "user=a&pass=b"
|
||||||
|
});
|
||||||
|
|
||||||
|
// FormData / Blob / ReadableStream / raw string → passed through untouched
|
||||||
|
await $fetch.post('/upload', { body: formData });
|
||||||
|
await $fetch.post('/ndjson', { body: '{"a":1}\n{"b":2}' }); // you own content-type
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response type detection
|
||||||
|
|
||||||
|
The body is decoded from the response `Content-Type`, or you can force it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const text = await $fetch<string, 'text'>('/readme', { responseType: 'text' });
|
||||||
|
const blob = await $fetch<Blob, 'blob'>('/avatar.png', { responseType: 'blob' });
|
||||||
|
const buf = await $fetch<ArrayBuffer, 'arrayBuffer'>('/file', { responseType: 'arrayBuffer' });
|
||||||
|
const sse = await $fetch<ReadableStream, 'stream'>('/events', { responseType: 'stream' });
|
||||||
|
|
||||||
|
// Custom parser (e.g. superjson, devalue)
|
||||||
|
const data = await $fetch('/data', { parseResponse: txt => superjson.parse(txt) });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Errors that throw
|
||||||
|
|
||||||
|
Any `4xx`/`5xx` rejects with a `FetchError` carrying the request, parsed body, and status.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { FetchError } from '@robonen/fetch';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/users/999');
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof FetchError) {
|
||||||
|
err.status; // 404
|
||||||
|
err.statusText; // "Not Found"
|
||||||
|
err.data; // parsed error body
|
||||||
|
err.request; // the URL/Request
|
||||||
|
err.options; // resolved request options (hooks stripped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt out — get the parsed body even on error responses
|
||||||
|
const body = await $fetch('/maybe-404', { ignoreResponseError: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry
|
||||||
|
|
||||||
|
Retries are built in. Defaults: **1 retry for non-payload methods** (GET/HEAD…), **0 for payload methods** (POST/PUT/PATCH/DELETE), on status `408, 409, 425, 429, 500, 502, 503, 504`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await $fetch('/flaky', { retry: 3 }); // up to 3 retries
|
||||||
|
await $fetch('/flaky', { retry: 3, retryDelay: 200 }); // 200ms between attempts
|
||||||
|
await $fetch('/flaky', { retry: 3, retryDelay: ctx => ctx.response?.status === 429 ? 1000 : 200 });
|
||||||
|
await $fetch('/flaky', { retryStatusCodes: [429, 503] }); // custom allowlist
|
||||||
|
await $fetch('/flaky', { retry: false }); // disable
|
||||||
|
```
|
||||||
|
|
||||||
|
A **user-initiated abort never retries**; a timeout does.
|
||||||
|
|
||||||
|
### Timeout
|
||||||
|
|
||||||
|
Backed by `AbortSignal.timeout`, composed with any signal you pass. The timeout applies **per attempt**, so a slow attempt that times out still gets retried with a fresh signal.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await $fetch('/slow', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Combine with your own AbortController — both are honoured
|
||||||
|
const controller = new AbortController();
|
||||||
|
await $fetch('/slow', { timeout: 5000, signal: controller.signal });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle hooks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await $fetch('/users', {
|
||||||
|
onRequest: (ctx) => {
|
||||||
|
ctx.options.headers.set('authorization', `Bearer ${token}`);
|
||||||
|
},
|
||||||
|
onResponse: (ctx) => {
|
||||||
|
console.log(ctx.response.status, ctx.response._data);
|
||||||
|
},
|
||||||
|
onResponseError: (ctx) => {
|
||||||
|
report(ctx.response.status, ctx.request);
|
||||||
|
},
|
||||||
|
onRequestError: (ctx) => {
|
||||||
|
console.error('network failure', ctx.error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Hooks accept a single function or an array, and run after any plugin hooks for the same phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instances: `create` / `extend`
|
||||||
|
|
||||||
|
Derive a configured instance. Defaults and plugins are merged; child wins on conflicts.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const api = $fetch.create({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
headers: { 'x-app': 'web' },
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `extend` is an alias for `create` — layer on more defaults / plugins
|
||||||
|
const billing = api.extend({ baseURL: 'https://billing.example.com' });
|
||||||
|
|
||||||
|
await api('/users'); // x-app + retry:2 inherited
|
||||||
|
await billing('/invoices'); // inherits headers/retry, overrides baseURL
|
||||||
|
```
|
||||||
|
|
||||||
|
Need the raw `Response`, or the underlying native fetch?
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const res = await $fetch.raw<User>('/users/1');
|
||||||
|
res._data; // parsed body
|
||||||
|
res.headers; // full Response API
|
||||||
|
|
||||||
|
await $fetch.native('https://example.com'); // untouched globalThis.fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
A plugin is a reusable bundle of **defaults**, **typed options**, **lifecycle hooks**, and optional **`execute` middleware**. Plugins are composed once at `createFetch` time — attaching them adds zero per-request overhead beyond the hooks themselves.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createFetch, definePlugin } from '@robonen/fetch';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth header injection — with a typed per-request option
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const auth = definePlugin<'auth', { token?: string }>({
|
||||||
|
name: 'auth',
|
||||||
|
hooks: {
|
||||||
|
onRequest: (ctx) => {
|
||||||
|
const token = (ctx.options as { token?: string }).token;
|
||||||
|
if (token) ctx.options.headers.set('authorization', `Bearer ${token}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = createFetch({ plugins: [auth] });
|
||||||
|
|
||||||
|
await api('/me', { token: 'xyz' }); // `token` is type-checked thanks to the plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token auto-refresh on 401
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function createAuthPlugin(getAccessToken: () => Promise<string>) {
|
||||||
|
let current: Promise<string> | undefined;
|
||||||
|
const refresh = () => (current ??= getAccessToken().finally(() => { current = undefined; }));
|
||||||
|
|
||||||
|
return definePlugin<'auth', { skipAuth?: boolean }>({
|
||||||
|
name: 'auth',
|
||||||
|
defaults: { retry: 1, retryStatusCodes: [401, 408, 429, 500, 502, 503, 504] },
|
||||||
|
hooks: {
|
||||||
|
onRequest: async (ctx) => {
|
||||||
|
if ((ctx.options as { skipAuth?: boolean }).skipAuth) return;
|
||||||
|
ctx.options.headers.set('authorization', `Bearer ${await refresh()}`);
|
||||||
|
},
|
||||||
|
onResponseError: async (ctx) => {
|
||||||
|
if (ctx.response.status !== 401) return;
|
||||||
|
current = undefined; // invalidate; the retry picks up a fresh token
|
||||||
|
ctx.options.headers.set('authorization', `Bearer ${await refresh()}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response envelope unwrapping — `{ data, meta }` → `data`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const unwrap = definePlugin({
|
||||||
|
name: 'unwrap',
|
||||||
|
hooks: {
|
||||||
|
onResponse: (ctx) => {
|
||||||
|
const body = ctx.response._data as { data?: unknown } | undefined;
|
||||||
|
if (body && typeof body === 'object' && 'data' in body) {
|
||||||
|
ctx.response._data = body.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### `execute` middleware — onion-style wrapping
|
||||||
|
|
||||||
|
`execute` wraps the whole fetch attempt (the built-in `retry` is itself an `execute` middleware). Middlewares may call `next()` zero, one, or many times.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const logger = definePlugin({
|
||||||
|
name: 'logger',
|
||||||
|
execute: async (ctx, next) => {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
await next(); // run the attempt (+ inner middlewares)
|
||||||
|
console.log(`${ctx.request} → ${ctx.response?.status} in ${(performance.now() - start) | 0}ms`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`${ctx.request} failed in ${(performance.now() - start) | 0}ms`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composing — order matters
|
||||||
|
|
||||||
|
Hooks run in registration order; the user's per-request hook runs last.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const api = createFetch({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
plugins: [createAuthPlugin(fetchToken), logger, unwrap],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Children inherit every parent plugin and may add their own
|
||||||
|
const idempotency = definePlugin<'idempotency', { idempotencyKey?: string }>({
|
||||||
|
name: 'idempotency',
|
||||||
|
hooks: {
|
||||||
|
onRequest: (ctx) => {
|
||||||
|
const m = (ctx.options.method ?? 'GET').toUpperCase();
|
||||||
|
if (m === 'GET' || m === 'HEAD') return;
|
||||||
|
const key = (ctx.options as { idempotencyKey?: string }).idempotencyKey ?? crypto.randomUUID();
|
||||||
|
ctx.options.headers.set('idempotency-key', key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const billing = api.extend({ baseURL: 'https://billing.example.com' }, { plugins: [idempotency] });
|
||||||
|
await billing('/invoices', { method: 'POST', body: { amount: 100 } });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
### `$fetch(request, options?)` / `createFetch(globalOptions?)`
|
||||||
|
|
||||||
|
| Option | Type | Description |
|
||||||
|
| ------------------- | ------------------------------------- | ----------- |
|
||||||
|
| `baseURL` | `string` | Prepended to relative request URLs. |
|
||||||
|
| `query` | `Record<string, …>` | Serialized and appended to the URL. |
|
||||||
|
| `body` | `RequestInit['body'] \| object \| array` | Objects are JSON-serialized. |
|
||||||
|
| `responseType` | `'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'stream'` | Forces the parse mode. |
|
||||||
|
| `parseResponse` | `(text: string) => T` | Custom body parser. |
|
||||||
|
| `ignoreResponseError` | `boolean` | Don't throw on `4xx`/`5xx`. |
|
||||||
|
| `retry` | `number \| false` | Retry attempts on failure. |
|
||||||
|
| `retryDelay` | `number \| (ctx) => number` | Delay between retries. |
|
||||||
|
| `retryStatusCodes` | `readonly number[]` | Status codes that trigger a retry. |
|
||||||
|
| `timeout` | `number` | Per-attempt timeout in ms. |
|
||||||
|
| `onRequest` / `onResponse` / `onRequestError` / `onResponseError` | hook(s) | Lifecycle callbacks. |
|
||||||
|
|
||||||
|
…plus every native `RequestInit` field (`headers`, `method`, `signal`, `credentials`, …).
|
||||||
|
|
||||||
|
### Instance members
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
| ----------------- | ----------- |
|
||||||
|
| `$fetch(req, o?)` | Returns the parsed body. |
|
||||||
|
| `$fetch.raw(req, o?)` | Returns the full `Response` with a parsed `_data`. |
|
||||||
|
| `$fetch.get/post/put/patch/delete/head` | Method shortcuts. |
|
||||||
|
| `$fetch.create(defaults?, globalOptions?)` | New instance with merged config. |
|
||||||
|
| `$fetch.extend(...)` | Alias for `create`. |
|
||||||
|
| `$fetch.native` | The underlying `globalThis.fetch`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0 © Robonen Andrew
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
|
||||||
|
|
||||||
|
export default compose(base, typescript, imports, stylistic);
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
|
|
||||||
import { defineConfig } from 'oxlint';
|
|
||||||
|
|
||||||
export default defineConfig(compose(base, typescript, imports, stylistic));
|
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint:check": "oxlint -c oxlint.config.ts",
|
"lint:check": "eslint .",
|
||||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vitest dev",
|
"dev": "vitest dev",
|
||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
@@ -46,11 +46,10 @@
|
|||||||
"@robonen/stdlib": "workspace:*"
|
"@robonen/stdlib": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@robonen/oxlint": "workspace:*",
|
"@robonen/eslint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
"@stylistic/eslint-plugin": "catalog:",
|
"eslint": "catalog:",
|
||||||
"oxlint": "catalog:",
|
|
||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,21 @@ describe('JSON body serialisation', () => {
|
|||||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
expect(init.body).toBe('key=value');
|
expect(init.body).toBe('key=value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a raw string body through without forcing a JSON content-type', async () => {
|
||||||
|
const fetchMock = makeFetchMock({ ok: true });
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock });
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com/raw', {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'plain text payload',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(init.body).toBe('plain text payload');
|
||||||
|
expect((init.headers as Headers).get('content-type')).toBeNull();
|
||||||
|
expect((init.headers as Headers).get('accept')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -328,6 +343,50 @@ describe('retry', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(data).toEqual({ ok: true });
|
expect(data).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not retry a user-initiated abort', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
const signal = init.signal as AbortSignal;
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock });
|
||||||
|
|
||||||
|
const promise = $fetch('https://api.example.com/cancel', {
|
||||||
|
signal: controller.signal,
|
||||||
|
retry: 3,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a stale error on a successful retry before onResponse runs', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new TypeError('network down'))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response('{"ok":true}', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock });
|
||||||
|
|
||||||
|
let errorInResponseHook: unknown = 'unset';
|
||||||
|
const data = await $fetch('https://api.example.com/flaky', {
|
||||||
|
onResponse: (ctx) => {
|
||||||
|
errorInResponseHook = ctx.error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data).toEqual({ ok: true });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(errorInResponseHook).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -524,4 +583,37 @@ describe('timeout', () => {
|
|||||||
|
|
||||||
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a fresh, un-aborted timeout signal on each retry attempt', async () => {
|
||||||
|
let attempt = 0;
|
||||||
|
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||||
|
attempt += 1;
|
||||||
|
const signal = init.signal as AbortSignal;
|
||||||
|
|
||||||
|
// First attempt hangs until its own timeout fires.
|
||||||
|
if (attempt === 1) {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry must receive a brand-new signal, not the already-aborted one.
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response('{"ok":true}', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock });
|
||||||
|
const promise = $fetch('https://api.example.com/slow', { timeout: 100 });
|
||||||
|
|
||||||
|
// Fire attempt-1 timeout and let the retry proceed to attempt 2.
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({ ok: true });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+214
-200
@@ -1,32 +1,127 @@
|
|||||||
import type { $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, MappedResponseType, ResponseType } from './types';
|
import type { $Fetch, CreateFetchOptions, Fetch, FetchContext, FetchHook, FetchOptions, FetchPlugin, FetchRequest, FetchResponse, MappedResponseType, MergePluginOptions, ResolvedFetchOptions, ResponseType } from './types';
|
||||||
|
import { isString } from '@robonen/stdlib';
|
||||||
import { FetchError, createFetchError } from './error';
|
import { FetchError, createFetchError } from './error';
|
||||||
|
import { composePlugins, runHookPhase } from './plugin';
|
||||||
|
import { retryPlugin, timeoutPlugin } from './plugins';
|
||||||
import {
|
import {
|
||||||
NULL_BODY_STATUSES,
|
NULL_BODY_STATUSES,
|
||||||
buildURL,
|
buildURL,
|
||||||
callHooks,
|
|
||||||
detectResponseType,
|
detectResponseType,
|
||||||
isJSONSerializable,
|
isJSONSerializable,
|
||||||
isPayloadMethod,
|
isPayloadMethod,
|
||||||
joinURL,
|
joinURL,
|
||||||
resolveFetchOptions,
|
resolveFetchOptions,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { isFunction, isNumber, isString, retry } from '@robonen/stdlib';
|
|
||||||
|
|
||||||
function assignResponseData(response: { _data?: unknown }, data: unknown): void {
|
// ---------------------------------------------------------------------------
|
||||||
response._data = data;
|
// Module-scope constants and helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in plugins prepended to every `createFetch` call.
|
||||||
|
* Frozen tuple: stable array identity + V8 can treat entries as constants.
|
||||||
|
*/
|
||||||
|
const BUILTIN_PLUGINS: readonly FetchPlugin[] = /* @__PURE__ */ Object.freeze([
|
||||||
|
retryPlugin(),
|
||||||
|
timeoutPlugin(),
|
||||||
|
]) as readonly FetchPlugin[];
|
||||||
|
|
||||||
|
/** Default HTTP method — hoisted once so string literal is interned and reused */
|
||||||
|
const DEFAULT_METHOD = 'GET';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V8/Node expose `Error.captureStackTrace`, browsers do not. Resolve once at
|
||||||
|
* module load so the error path doesn't pay a dynamic lookup + typeof check.
|
||||||
|
*/
|
||||||
|
const captureStackTrace: typeof Error.captureStackTrace | undefined
|
||||||
|
= typeof Error.captureStackTrace === 'function' ? Error.captureStackTrace : undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a successful fetch Response body into `response._data` according to
|
||||||
|
* the caller's `parseResponse` / `responseType` options (or detected from
|
||||||
|
* Content-Type). Hoisted to module scope so `$fetchRaw` / `runAttempt`
|
||||||
|
* stay small and inlineable.
|
||||||
|
*/
|
||||||
|
async function parseResponseBody(
|
||||||
|
response: FetchResponse<unknown>,
|
||||||
|
options: ResolvedFetchOptions<ResponseType, unknown>,
|
||||||
|
method: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
response.body === null
|
||||||
|
|| method === 'HEAD'
|
||||||
|
|| NULL_BODY_STATUSES.has(response.status)
|
||||||
|
) return;
|
||||||
|
|
||||||
|
const parseResponse = options.parseResponse;
|
||||||
|
const responseType = parseResponse !== undefined
|
||||||
|
? 'json'
|
||||||
|
: (options.responseType ?? detectResponseType(response.headers.get('content-type') ?? ''));
|
||||||
|
|
||||||
|
switch (responseType) {
|
||||||
|
case 'json': {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length > 0) {
|
||||||
|
response._data = parseResponse !== undefined ? parseResponse(text) : JSON.parse(text);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'stream': {
|
||||||
|
response._data = response.body;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
response._data = await response[responseType]();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP status codes that trigger automatic retry by default */
|
/**
|
||||||
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
* Serialize the request body for payload-bearing methods and set the
|
||||||
408, // Request Timeout
|
* appropriate content-type / accept / duplex hints. Mutates `options` in place.
|
||||||
409, // Conflict
|
*/
|
||||||
425, // Too Early (Experimental)
|
function serializeRequestBody(options: ResolvedFetchOptions<ResponseType, unknown>, method: string): void {
|
||||||
429, // Too Many Requests
|
const body = options.body;
|
||||||
500, // Internal Server Error
|
if (body === undefined || body === null || !isPayloadMethod(method)) return;
|
||||||
502, // Bad Gateway
|
|
||||||
503, // Service Unavailable
|
if (isJSONSerializable(body)) {
|
||||||
504, // Gateway Timeout
|
// A raw string body is passed through untouched — the caller owns its
|
||||||
]);
|
// content-type (it may be plain text, NDJSON, GraphQL, etc.).
|
||||||
|
if (isString(body)) return;
|
||||||
|
|
||||||
|
const headers = options.headers;
|
||||||
|
const contentType = headers.get('content-type');
|
||||||
|
|
||||||
|
options.body = contentType === 'application/x-www-form-urlencoded'
|
||||||
|
? new URLSearchParams(body as Record<string, string>).toString()
|
||||||
|
: JSON.stringify(body);
|
||||||
|
|
||||||
|
if (contentType === null) {
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
if (!headers.has('accept')) {
|
||||||
|
headers.set('accept', 'application/json');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Streams body — mark duplex if caller didn't set it explicitly.
|
||||||
|
// `options.duplex === undefined` avoids the `in` operator slow path on
|
||||||
|
// dictionary-mode objects and keeps the IC monomorphic on a single key load.
|
||||||
|
if (typeof (body as ReadableStream).pipeTo === 'function' && options.duplex === undefined) {
|
||||||
|
options.duplex = 'half';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut for method-specialized helpers (get/post/...). Avoids a spread
|
||||||
|
* allocation when `options` is undefined, which is the common case.
|
||||||
|
*/
|
||||||
|
function withMethod<O>(options: O | undefined, method: string): O & { method: string } {
|
||||||
|
return options === undefined
|
||||||
|
? ({ method } as O & { method: string })
|
||||||
|
: ({ ...options, method } as O & { method: string });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// createFetch
|
// createFetch
|
||||||
@@ -37,94 +132,85 @@ const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set(
|
|||||||
* @category Fetch
|
* @category Fetch
|
||||||
* @description Creates a configured $fetch instance
|
* @description Creates a configured $fetch instance
|
||||||
*
|
*
|
||||||
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation
|
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults, custom fetch implementation, and plugins
|
||||||
* @returns {$Fetch} Configured fetch instance
|
* @returns {$Fetch} Configured fetch instance
|
||||||
*
|
*
|
||||||
* @since 0.0.1
|
* @since 0.0.1
|
||||||
*/
|
*/
|
||||||
export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
export function createFetch<Plugins extends readonly FetchPlugin[] = []>(
|
||||||
|
globalOptions: CreateFetchOptions<Plugins> = {},
|
||||||
|
): $Fetch<Plugins> {
|
||||||
const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
|
const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
|
||||||
|
|
||||||
|
// Built-ins compose first, user plugins layer on top. composePlugins runs once.
|
||||||
|
const userPlugins = (globalOptions.plugins ?? []) as readonly FetchPlugin[];
|
||||||
|
const allPlugins = userPlugins.length === 0
|
||||||
|
? BUILTIN_PLUGINS
|
||||||
|
: [...BUILTIN_PLUGINS, ...userPlugins];
|
||||||
|
|
||||||
|
const composed = composePlugins(
|
||||||
|
allPlugins,
|
||||||
|
globalOptions.defaults as FetchOptions | undefined,
|
||||||
|
);
|
||||||
|
const composedHooks = composed.hooks;
|
||||||
|
const composedExecute = composed.execute;
|
||||||
|
const composedDefaults = composed.defaults;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// executeFetch — performs a single fetch attempt (no retry logic)
|
// runAttempt — a single fetch attempt (fetch + body parse + response hooks)
|
||||||
|
// Closure over fetchImpl + composedHooks; stable shape per instance.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async function executeFetch<T = unknown, R extends ResponseType = 'json'>(context: FetchContext<T, R>): Promise<FetchResponse<T>> {
|
async function runAttempt<T, R extends ResponseType>(context: FetchContext<T, R>): Promise<void> {
|
||||||
// Actual fetch call
|
const options = context.options;
|
||||||
|
|
||||||
|
// Reset per-attempt outcome so a successful retry never carries a stale
|
||||||
|
// error/response from a previous attempt into the response hooks.
|
||||||
|
context.error = undefined;
|
||||||
|
context.response = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
context.response = await fetchImpl(context.request, context.options as RequestInit);
|
context.response = await fetchImpl(context.request, options as RequestInit);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
context.error = err as Error;
|
context.error = err as Error;
|
||||||
|
|
||||||
if (context.options.onRequestError !== undefined) {
|
if (composedHooks.onRequestError !== undefined || options.onRequestError !== undefined) {
|
||||||
await callHooks(
|
await runHookPhase(
|
||||||
|
composedHooks.onRequestError as ReadonlyArray<FetchHook<FetchContext<T, R> & { error: Error }>> | undefined,
|
||||||
|
options.onRequestError,
|
||||||
context as FetchContext<T, R> & { error: Error },
|
context as FetchContext<T, R> & { error: Error },
|
||||||
context.options.onRequestError,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw createFetchError(context);
|
throw createFetchError(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response body parsing
|
const response = context.response;
|
||||||
const method = context.options.method ?? 'GET';
|
const method = options.method ?? DEFAULT_METHOD;
|
||||||
const hasBody
|
|
||||||
= context.response.body !== null
|
|
||||||
&& !NULL_BODY_STATUSES.has(context.response.status)
|
|
||||||
&& method !== 'HEAD';
|
|
||||||
|
|
||||||
if (hasBody) {
|
await parseResponseBody(response as FetchResponse<unknown>, options as unknown as ResolvedFetchOptions<ResponseType, unknown>, method);
|
||||||
const responseType
|
|
||||||
= context.options.parseResponse !== undefined
|
|
||||||
? 'json'
|
|
||||||
: (context.options.responseType
|
|
||||||
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
|
|
||||||
|
|
||||||
switch (responseType) {
|
if (composedHooks.onResponse !== undefined || options.onResponse !== undefined) {
|
||||||
case 'json': {
|
await runHookPhase(
|
||||||
const text = await context.response.text();
|
composedHooks.onResponse as ReadonlyArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>> | undefined,
|
||||||
if (text) {
|
options.onResponse,
|
||||||
context.response._data
|
|
||||||
= context.options.parseResponse !== undefined
|
|
||||||
? context.options.parseResponse(text)
|
|
||||||
: JSON.parse(text);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'stream': {
|
|
||||||
assignResponseData(context.response, context.response.body);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
assignResponseData(context.response, await context.response[responseType]());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.options.onResponse !== undefined) {
|
|
||||||
await callHooks(
|
|
||||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||||
context.options.onResponse,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const status = response.status;
|
||||||
!context.options.ignoreResponseError
|
if (!options.ignoreResponseError && status >= 400 && status < 600) {
|
||||||
&& context.response.status >= 400
|
if (composedHooks.onResponseError !== undefined || options.onResponseError !== undefined) {
|
||||||
&& context.response.status < 600
|
await runHookPhase(
|
||||||
) {
|
composedHooks.onResponseError as ReadonlyArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>> | undefined,
|
||||||
if (context.options.onResponseError !== undefined) {
|
options.onResponseError,
|
||||||
await callHooks(
|
|
||||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||||
context.options.onResponseError,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw createFetchError(context);
|
throw createFetchError(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -138,149 +224,69 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
|||||||
_request: FetchRequest,
|
_request: FetchRequest,
|
||||||
_options?: FetchOptions<R, T>,
|
_options?: FetchOptions<R, T>,
|
||||||
): Promise<FetchResponse<MappedResponseType<R, T>>> {
|
): Promise<FetchResponse<MappedResponseType<R, T>>> {
|
||||||
|
// Fixed key order → single hidden class for FetchContext across all requests
|
||||||
const context: FetchContext<T, R> = {
|
const context: FetchContext<T, R> = {
|
||||||
request: _request,
|
request: _request,
|
||||||
options: resolveFetchOptions(
|
options: resolveFetchOptions(_request, _options, composedDefaults),
|
||||||
_request,
|
|
||||||
_options,
|
|
||||||
globalOptions.defaults,
|
|
||||||
),
|
|
||||||
response: undefined,
|
response: undefined,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalise method to uppercase before any hook or header logic
|
const options = context.options;
|
||||||
if (context.options.method !== undefined) {
|
|
||||||
context.options.method = context.options.method.toUpperCase();
|
// Normalise method to uppercase before any hook or header logic, then
|
||||||
|
// cache the resolved method string for reuse below (avoids repeating
|
||||||
|
// `options.method ?? DEFAULT_METHOD` at every downstream call site).
|
||||||
|
let method: string;
|
||||||
|
if (options.method !== undefined) {
|
||||||
|
method = options.method.toUpperCase();
|
||||||
|
options.method = method;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
method = DEFAULT_METHOD;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.options.onRequest !== undefined) {
|
if (composedHooks.onRequest !== undefined || options.onRequest !== undefined) {
|
||||||
await callHooks(context, context.options.onRequest);
|
await runHookPhase(
|
||||||
|
composedHooks.onRequest as ReadonlyArray<FetchHook<FetchContext<T, R>>> | undefined,
|
||||||
|
options.onRequest,
|
||||||
|
context,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL transformations — only when request is a plain string
|
// URL transformations — only when request is a plain string
|
||||||
if (isString(context.request)) {
|
if (isString(context.request)) {
|
||||||
if (context.options.baseURL !== undefined) {
|
if (options.baseURL !== undefined) {
|
||||||
context.request = joinURL(context.options.baseURL, context.request);
|
context.request = joinURL(options.baseURL, context.request);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = context.options.query ?? context.options.params;
|
const query = options.query ?? options.params;
|
||||||
if (query !== undefined) {
|
if (query !== undefined) {
|
||||||
context.request = buildURL(context.request, query);
|
context.request = buildURL(context.request, query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body serialisation
|
serializeRequestBody(options as unknown as ResolvedFetchOptions<ResponseType, unknown>, method);
|
||||||
const method = context.options.method ?? 'GET';
|
|
||||||
if (context.options.body !== undefined && context.options.body !== null && isPayloadMethod(method)) {
|
|
||||||
if (isJSONSerializable(context.options.body)) {
|
|
||||||
const contentType = context.options.headers.get('content-type');
|
|
||||||
|
|
||||||
if (!isString(context.options.body)) {
|
|
||||||
context.options.body
|
|
||||||
= contentType === 'application/x-www-form-urlencoded'
|
|
||||||
? new URLSearchParams(
|
|
||||||
context.options.body as Record<string, string>,
|
|
||||||
).toString()
|
|
||||||
: JSON.stringify(context.options.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === null) {
|
|
||||||
context.options.headers.set('content-type', 'application/json');
|
|
||||||
}
|
|
||||||
if (!context.options.headers.has('accept')) {
|
|
||||||
context.options.headers.set('accept', 'application/json');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (
|
|
||||||
// Web Streams API body
|
|
||||||
typeof (context.options.body as ReadableStream | null)?.pipeTo === 'function'
|
|
||||||
) {
|
|
||||||
if (!('duplex' in context.options)) {
|
|
||||||
context.options.duplex = 'half';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout via AbortSignal — compose with any caller-supplied signal
|
|
||||||
if (context.options.timeout !== undefined) {
|
|
||||||
const timeoutSignal = AbortSignal.timeout(context.options.timeout);
|
|
||||||
context.options.signal
|
|
||||||
= context.options.signal !== undefined
|
|
||||||
? AbortSignal.any([timeoutSignal, context.options.signal as AbortSignal])
|
|
||||||
: timeoutSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Retry configuration — computed once, not per attempt
|
// Execute — fast path (no execute middleware) vs onion chain
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
const retryDisabled = context.options.retry === false;
|
|
||||||
const maxRetries = retryDisabled
|
|
||||||
? 0
|
|
||||||
: isNumber(context.options.retry)
|
|
||||||
? context.options.retry
|
|
||||||
: isPayloadMethod(method)
|
|
||||||
? 0
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
if (maxRetries === 0) {
|
|
||||||
try {
|
try {
|
||||||
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
if (composedExecute === undefined) {
|
||||||
|
await runAttempt(context);
|
||||||
}
|
}
|
||||||
catch (err) {
|
else {
|
||||||
const error = err instanceof FetchError ? err : createFetchError(context);
|
await composedExecute(context as unknown as FetchContext, () => runAttempt(context));
|
||||||
|
|
||||||
if (isFunction(Error.captureStackTrace)) {
|
|
||||||
Error.captureStackTrace(error, $fetchRaw);
|
|
||||||
}
|
}
|
||||||
|
return context.response as FetchResponse<MappedResponseType<R, T>>;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry path — delegates to stdlib retry with iterative while-loop
|
|
||||||
try {
|
|
||||||
return await retry(
|
|
||||||
async ({ stop }) => {
|
|
||||||
try {
|
|
||||||
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
// User-initiated abort (not timeout) should not be retried
|
|
||||||
const isAbort
|
|
||||||
= context.error !== undefined
|
|
||||||
&& context.error.name === 'AbortError'
|
|
||||||
&& context.options.timeout === undefined;
|
|
||||||
|
|
||||||
if (isAbort) {
|
|
||||||
stop(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// stdlib retry counts total attempts; fetch `retry` means retries only
|
|
||||||
times: maxRetries + 1,
|
|
||||||
delay: isFunction(context.options.retryDelay)
|
|
||||||
? () => (context.options.retryDelay as (ctx: FetchContext<T, R>) => number)(context)
|
|
||||||
: (context.options.retryDelay ?? 0),
|
|
||||||
shouldRetry: () => {
|
|
||||||
const status = context.response?.status ?? 500;
|
|
||||||
return context.options.retryStatusCodes !== undefined
|
|
||||||
? context.options.retryStatusCodes.includes(status)
|
|
||||||
: DEFAULT_RETRY_STATUS_CODES.has(status);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const error = err instanceof FetchError ? err : createFetchError(context);
|
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||||
|
|
||||||
// V8 / Node.js — clip internal frames from the error stack trace
|
// V8 / Node.js — clip internal frames from the error stack trace
|
||||||
if (isFunction(Error.captureStackTrace)) {
|
if (captureStackTrace !== undefined) {
|
||||||
Error.captureStackTrace(error, $fetchRaw);
|
captureStackTrace(error, $fetchRaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@@ -297,35 +303,43 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
|||||||
): Promise<MappedResponseType<R, T>> {
|
): Promise<MappedResponseType<R, T>> {
|
||||||
const response = await $fetchRaw<T, R>(request, options);
|
const response = await $fetchRaw<T, R>(request, options);
|
||||||
return response._data as MappedResponseType<R, T>;
|
return response._data as MappedResponseType<R, T>;
|
||||||
} as $Fetch;
|
} as $Fetch<Plugins>;
|
||||||
|
|
||||||
$fetch.raw = $fetchRaw;
|
$fetch.raw = $fetchRaw;
|
||||||
|
|
||||||
$fetch.native = (...args: Parameters<typeof fetchImpl>) => fetchImpl(...args);
|
$fetch.native = fetchImpl as Fetch;
|
||||||
|
|
||||||
$fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) =>
|
$fetch.create = (<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||||
createFetch({
|
defaults: FetchOptions = {},
|
||||||
...globalOptions,
|
customGlobalOptions: CreateFetchOptions<NewPlugins> = {},
|
||||||
...customGlobalOptions,
|
) =>
|
||||||
|
createFetch<[...Plugins, ...NewPlugins]>({
|
||||||
|
fetch: customGlobalOptions.fetch ?? globalOptions.fetch,
|
||||||
|
plugins: [
|
||||||
|
...((globalOptions.plugins ?? []) as readonly FetchPlugin[]),
|
||||||
|
...((customGlobalOptions.plugins ?? []) as readonly FetchPlugin[]),
|
||||||
|
] as unknown as [...Plugins, ...NewPlugins],
|
||||||
defaults: {
|
defaults: {
|
||||||
...globalOptions.defaults,
|
...(globalOptions.defaults as FetchOptions | undefined),
|
||||||
...customGlobalOptions.defaults,
|
...(customGlobalOptions.defaults as FetchOptions | undefined),
|
||||||
...defaults,
|
...defaults,
|
||||||
},
|
} as FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||||
});
|
})) as $Fetch<Plugins>['create'];
|
||||||
|
|
||||||
$fetch.extend = $fetch.create;
|
$fetch.extend = $fetch.create;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Method shortcuts
|
// Method shortcuts — `withMethod` keeps the fast path allocation-free
|
||||||
|
// when no per-request options are provided.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
$fetch.get = (request, options) => $fetch(request, { ...options, method: 'GET' });
|
type ShortcutOptions = FetchOptions & MergePluginOptions<Plugins>;
|
||||||
$fetch.post = (request, options) => $fetch(request, { ...options, method: 'POST' });
|
$fetch.get = ((req, opt) => $fetch(req, withMethod(opt, 'GET') as ShortcutOptions)) as $Fetch<Plugins>['get'];
|
||||||
$fetch.put = (request, options) => $fetch(request, { ...options, method: 'PUT' });
|
$fetch.post = ((req, opt) => $fetch(req, withMethod(opt, 'POST') as ShortcutOptions)) as $Fetch<Plugins>['post'];
|
||||||
$fetch.patch = (request, options) => $fetch(request, { ...options, method: 'PATCH' });
|
$fetch.put = ((req, opt) => $fetch(req, withMethod(opt, 'PUT') as ShortcutOptions)) as $Fetch<Plugins>['put'];
|
||||||
$fetch.delete = (request, options) => $fetch(request, { ...options, method: 'DELETE' });
|
$fetch.patch = ((req, opt) => $fetch(req, withMethod(opt, 'PATCH') as ShortcutOptions)) as $Fetch<Plugins>['patch'];
|
||||||
$fetch.head = (request, options) => $fetchRaw(request, { ...options, method: 'HEAD' });
|
$fetch.delete = ((req, opt) => $fetch(req, withMethod(opt, 'DELETE') as ShortcutOptions)) as $Fetch<Plugins>['delete'];
|
||||||
|
$fetch.head = ((req, opt) => $fetchRaw(req, withMethod(opt, 'HEAD') as ShortcutOptions)) as $Fetch<Plugins>['head'];
|
||||||
|
|
||||||
return $fetch;
|
return $fetch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { createFetch } from './fetch';
|
|||||||
|
|
||||||
export { createFetch } from './fetch';
|
export { createFetch } from './fetch';
|
||||||
export { FetchError, createFetchError } from './error';
|
export { FetchError, createFetchError } from './error';
|
||||||
|
export { composePlugins, definePlugin, runHookPhase } from './plugin';
|
||||||
|
export type { ComposedPlugins } from './plugin';
|
||||||
|
export { retryPlugin, timeoutPlugin } from './plugins';
|
||||||
export {
|
export {
|
||||||
isPayloadMethod,
|
isPayloadMethod,
|
||||||
isJSONSerializable,
|
isJSONSerializable,
|
||||||
@@ -17,13 +20,17 @@ export type {
|
|||||||
Fetch,
|
Fetch,
|
||||||
FetchContext,
|
FetchContext,
|
||||||
FetchErrorOptions,
|
FetchErrorOptions,
|
||||||
|
FetchExecuteMiddleware,
|
||||||
FetchHook,
|
FetchHook,
|
||||||
FetchHooks,
|
FetchHooks,
|
||||||
FetchOptions,
|
FetchOptions,
|
||||||
|
FetchPlugin,
|
||||||
FetchRequest,
|
FetchRequest,
|
||||||
FetchResponse,
|
FetchResponse,
|
||||||
IFetchError,
|
IFetchError,
|
||||||
MappedResponseType,
|
MappedResponseType,
|
||||||
|
MergePluginContext,
|
||||||
|
MergePluginOptions,
|
||||||
ResponseMap,
|
ResponseMap,
|
||||||
ResponseType,
|
ResponseType,
|
||||||
ResolvedFetchOptions,
|
ResolvedFetchOptions,
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import type { Fetch, FetchContext } from './types';
|
||||||
|
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
||||||
|
import { FetchError } from './error';
|
||||||
|
import { createFetch } from './fetch';
|
||||||
|
import { definePlugin } from './plugin';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeFetchMock(
|
||||||
|
body: unknown = { ok: true },
|
||||||
|
init: ResponseInit = { status: 200 },
|
||||||
|
contentType = 'application/json',
|
||||||
|
) {
|
||||||
|
return vi.fn<Fetch>().mockResolvedValue(
|
||||||
|
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||||
|
...init,
|
||||||
|
headers: { 'content-type': contentType, ...init.headers },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// definePlugin — identity + inference
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('definePlugin', () => {
|
||||||
|
it('returns the plugin object verbatim', () => {
|
||||||
|
const plugin = definePlugin({ name: 'noop' });
|
||||||
|
expect(plugin.name).toBe('noop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the const Name generic', () => {
|
||||||
|
const plugin = definePlugin({ name: 'auth' });
|
||||||
|
expectTypeOf(plugin.name).toEqualTypeOf<'auth'>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Defaults merging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('plugin defaults', () => {
|
||||||
|
it('applies plugin defaults to every request', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const baseUrl = definePlugin({
|
||||||
|
name: 'baseUrl',
|
||||||
|
defaults: { baseURL: 'https://api.example.com' },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [baseUrl] });
|
||||||
|
|
||||||
|
await $fetch('/users');
|
||||||
|
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string];
|
||||||
|
expect(url).toBe('https://api.example.com/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user defaults override plugin defaults', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'x',
|
||||||
|
defaults: { baseURL: 'https://plugin.example.com' },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({
|
||||||
|
fetch: fetchMock,
|
||||||
|
plugins: [plugin],
|
||||||
|
defaults: { baseURL: 'https://user.example.com' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await $fetch('/x');
|
||||||
|
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string];
|
||||||
|
expect(url).toBe('https://user.example.com/x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges headers from plugin defaults and user defaults', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'hdrs',
|
||||||
|
defaults: { headers: { 'x-plugin': 'p' } },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({
|
||||||
|
fetch: fetchMock,
|
||||||
|
plugins: [plugin],
|
||||||
|
defaults: { headers: { 'x-user': 'u' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Headers;
|
||||||
|
expect(headers.get('x-plugin')).toBe('p');
|
||||||
|
expect(headers.get('x-user')).toBe('u');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user headers win on conflict', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'hdrs',
|
||||||
|
defaults: { headers: { authorization: 'plugin' } },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({
|
||||||
|
fetch: fetchMock,
|
||||||
|
plugins: [plugin],
|
||||||
|
defaults: { headers: { authorization: 'user' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect((init.headers as Headers).get('authorization')).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook ordering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('plugin hooks', () => {
|
||||||
|
it('runs plugin hooks before per-request hooks', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const calls: string[] = [];
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'a',
|
||||||
|
hooks: {
|
||||||
|
onRequest: () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com', {
|
||||||
|
onRequest: () => {
|
||||||
|
calls.push('user');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual(['plugin', 'user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves plugin registration order across multiple plugins', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const calls: string[] = [];
|
||||||
|
const a = definePlugin({
|
||||||
|
name: 'a',
|
||||||
|
hooks: { onRequest: () => { calls.push('a'); } },
|
||||||
|
});
|
||||||
|
const b = definePlugin({
|
||||||
|
name: 'b',
|
||||||
|
hooks: { onRequest: () => { calls.push('b'); } },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [a, b] });
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
|
||||||
|
expect(calls).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports arrays of hooks inside a single plugin', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const calls: string[] = [];
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'multi',
|
||||||
|
hooks: {
|
||||||
|
onRequest: [
|
||||||
|
() => { calls.push('1'); },
|
||||||
|
() => { calls.push('2'); },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
|
||||||
|
expect(calls).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onResponse hook for successful responses', async () => {
|
||||||
|
const fetchMock = makeFetchMock({ id: 1 });
|
||||||
|
const seen: number[] = [];
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'r',
|
||||||
|
hooks: {
|
||||||
|
onResponse: (ctx) => {
|
||||||
|
if (ctx.response.status === 200) seen.push(ctx.response.status);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
|
||||||
|
expect(seen).toEqual([200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onResponseError hook for 4xx', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
|
||||||
|
const calls: string[] = [];
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'err',
|
||||||
|
hooks: { onResponseError: () => { calls.push('err'); } },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
|
||||||
|
await expect($fetch('https://api.example.com', { retry: false }))
|
||||||
|
.rejects.toBeInstanceOf(FetchError);
|
||||||
|
expect(calls).toEqual(['err']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onRequestError hook on network failure', async () => {
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new TypeError('offline'));
|
||||||
|
const calls: string[] = [];
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'net',
|
||||||
|
hooks: { onRequestError: () => { calls.push('net'); } },
|
||||||
|
});
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
|
||||||
|
await expect($fetch('https://api.example.com', { retry: false }))
|
||||||
|
.rejects.toBeInstanceOf(FetchError);
|
||||||
|
expect(calls).toEqual(['net']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('plugin setup', () => {
|
||||||
|
it('is called exactly once per createFetch', async () => {
|
||||||
|
const fetchMock = vi.fn<Fetch>().mockImplementation(async () =>
|
||||||
|
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||||
|
);
|
||||||
|
const setup = vi.fn();
|
||||||
|
const plugin = definePlugin({ name: 's', setup });
|
||||||
|
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
await $fetch('https://api.example.com');
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('receives the fully merged defaults', () => {
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'inspect',
|
||||||
|
defaults: { baseURL: 'https://plugin.example.com' },
|
||||||
|
setup: ({ defaults }) => {
|
||||||
|
expect(defaults.baseURL).toBe('https://user.example.com');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createFetch({
|
||||||
|
fetch: makeFetchMock({}),
|
||||||
|
plugins: [plugin],
|
||||||
|
defaults: { baseURL: 'https://user.example.com' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extend / create — plugin inheritance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('extend inherits plugins', () => {
|
||||||
|
it('child instance runs parent plugin hooks', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const calls: string[] = [];
|
||||||
|
const parentPlugin = definePlugin({
|
||||||
|
name: 'parent',
|
||||||
|
hooks: { onRequest: () => { calls.push('parent'); } },
|
||||||
|
});
|
||||||
|
const parent = createFetch({ fetch: fetchMock, plugins: [parentPlugin] });
|
||||||
|
const child = parent.extend({});
|
||||||
|
|
||||||
|
await child('https://api.example.com');
|
||||||
|
|
||||||
|
expect(calls).toEqual(['parent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('child plugin runs after parent plugin', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const calls: string[] = [];
|
||||||
|
const parentPlugin = definePlugin({
|
||||||
|
name: 'parent',
|
||||||
|
hooks: { onRequest: () => { calls.push('parent'); } },
|
||||||
|
});
|
||||||
|
const childPlugin = definePlugin({
|
||||||
|
name: 'child',
|
||||||
|
hooks: { onRequest: () => { calls.push('child'); } },
|
||||||
|
});
|
||||||
|
const parent = createFetch({ fetch: fetchMock, plugins: [parentPlugin] });
|
||||||
|
const child = parent.extend({}, { plugins: [childPlugin] });
|
||||||
|
|
||||||
|
await child('https://api.example.com', {
|
||||||
|
onRequest: () => { calls.push('user'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual(['parent', 'child', 'user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('child defaults override parent defaults', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const parent = createFetch({
|
||||||
|
fetch: fetchMock,
|
||||||
|
defaults: { baseURL: 'https://parent.example.com' },
|
||||||
|
});
|
||||||
|
const child = parent.extend({ baseURL: 'https://child.example.com' });
|
||||||
|
|
||||||
|
await child('/x');
|
||||||
|
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string];
|
||||||
|
expect(url).toBe('https://child.example.com/x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent plugin defaults persist through extend', async () => {
|
||||||
|
const fetchMock = makeFetchMock({});
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: 'base',
|
||||||
|
defaults: { baseURL: 'https://plugin.example.com' },
|
||||||
|
});
|
||||||
|
const parent = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||||
|
const child = parent.extend({});
|
||||||
|
|
||||||
|
await child('/x');
|
||||||
|
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string];
|
||||||
|
expect(url).toBe('https://plugin.example.com/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zero-regression: instance without plugins behaves exactly like before
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('no-plugin instances', () => {
|
||||||
|
it('skips the hook fast-path when neither plugin nor user hooks are set', async () => {
|
||||||
|
const fetchMock = makeFetchMock({ ok: true });
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock });
|
||||||
|
|
||||||
|
const data = await $fetch<{ ok: boolean }>('https://api.example.com');
|
||||||
|
|
||||||
|
expect(data).toEqual({ ok: true });
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Type-level checks — plugin OptionsExt flows into request options
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('type inference', () => {
|
||||||
|
it('adds plugin OptionsExt fields to request options', () => {
|
||||||
|
const auth = definePlugin<'auth', { token?: string }>({ name: 'auth' });
|
||||||
|
const _api = createFetch({ plugins: [auth] });
|
||||||
|
|
||||||
|
// Valid usage — `token` is known to the type system.
|
||||||
|
type ApiCall = Parameters<typeof _api>[1];
|
||||||
|
expectTypeOf<ApiCall>().toMatchTypeOf<{ token?: string } | undefined>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown fields when no plugin declares them', () => {
|
||||||
|
const api = createFetch();
|
||||||
|
|
||||||
|
// @ts-expect-error — `token` is not a known option on a plugin-less instance.
|
||||||
|
void (() => api('https://api.example.com', { token: 'x' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes context extension as a type-only carrier', () => {
|
||||||
|
const trace = definePlugin<'trace', object, { traceId: string }>({
|
||||||
|
name: 'trace',
|
||||||
|
});
|
||||||
|
|
||||||
|
expectTypeOf(trace.name).toEqualTypeOf<'trace'>();
|
||||||
|
// Sanity: FetchContext at runtime is still the base shape; ContextExt is advisory.
|
||||||
|
expectTypeOf<FetchContext>().toMatchTypeOf<{ request: unknown }>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import type { FetchExecuteMiddleware, FetchHook, FetchHooks, FetchOptions, FetchPlugin } from './types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// definePlugin — identity factory with type-safe inference
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name definePlugin
|
||||||
|
* @category Fetch
|
||||||
|
* @description Declares a typed fetch plugin. Identity function — returns its input
|
||||||
|
* verbatim at runtime, used only to narrow generics for strong option inference.
|
||||||
|
*
|
||||||
|
* @typeParam Name - Unique plugin identifier
|
||||||
|
* @typeParam OptionsExt - Extra fields contributed to FetchOptions by this plugin
|
||||||
|
* @typeParam ContextExt - Extra fields advisory for FetchContext
|
||||||
|
*
|
||||||
|
* @example <caption>Bearer token injection with typed per-request override</caption>
|
||||||
|
* const auth = definePlugin<'auth', { token?: string }>({
|
||||||
|
* name: 'auth',
|
||||||
|
* hooks: {
|
||||||
|
* onRequest: (ctx) => {
|
||||||
|
* const token = (ctx.options as { token?: string }).token;
|
||||||
|
* if (token !== undefined) ctx.options.headers.set('authorization', `Bearer ${token}`);
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const api = createFetch({ plugins: [auth] });
|
||||||
|
* await api('/me', { token: 'xyz' });
|
||||||
|
*
|
||||||
|
* @example <caption>Auto-refresh on 401 using a shared factory closure</caption>
|
||||||
|
* function createAuthPlugin(getAccessToken: () => Promise<string>) {
|
||||||
|
* let current: Promise<string> | undefined;
|
||||||
|
* const refresh = () => (current ??= getAccessToken().finally(() => { current = undefined; }));
|
||||||
|
*
|
||||||
|
* return definePlugin<'auth', { skipAuth?: boolean }>({
|
||||||
|
* name: 'auth',
|
||||||
|
* hooks: {
|
||||||
|
* onRequest: async (ctx) => {
|
||||||
|
* if ((ctx.options as { skipAuth?: boolean }).skipAuth) return;
|
||||||
|
* ctx.options.headers.set('authorization', `Bearer ${await refresh()}`);
|
||||||
|
* },
|
||||||
|
* onResponseError: async (ctx) => {
|
||||||
|
* if (ctx.response.status !== 401) return;
|
||||||
|
* // Invalidate cached token; next attempt via `retry` will pick up a fresh one.
|
||||||
|
* current = undefined;
|
||||||
|
* ctx.options.headers.set('authorization', `Bearer ${await refresh()}`);
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* defaults: { retry: 1, retryStatusCodes: [401, 408, 429, 500, 502, 503, 504] },
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example <caption>Idempotency-Key for unsafe methods</caption>
|
||||||
|
* const idempotency = definePlugin<'idempotency', { idempotencyKey?: string }>({
|
||||||
|
* name: 'idempotency',
|
||||||
|
* hooks: {
|
||||||
|
* onRequest: (ctx) => {
|
||||||
|
* const method = (ctx.options.method ?? 'GET').toUpperCase();
|
||||||
|
* if (method === 'GET' || method === 'HEAD') return;
|
||||||
|
* const key = (ctx.options as { idempotencyKey?: string }).idempotencyKey ?? crypto.randomUUID();
|
||||||
|
* ctx.options.headers.set('idempotency-key', key);
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example <caption>Response envelope unwrapping — { data, meta } → data</caption>
|
||||||
|
* interface Envelope<T> { readonly data: T; readonly meta?: Record<string, unknown> }
|
||||||
|
*
|
||||||
|
* const unwrap = definePlugin({
|
||||||
|
* name: 'unwrap',
|
||||||
|
* hooks: {
|
||||||
|
* onResponse: (ctx) => {
|
||||||
|
* const body = ctx.response._data as Envelope<unknown> | undefined;
|
||||||
|
* if (body !== undefined && typeof body === 'object' && 'data' in body) {
|
||||||
|
* ctx.response._data = body.data;
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example <caption>Timing + structured logger using WeakMap-keyed state</caption>
|
||||||
|
* function createLoggerPlugin(sink: (record: { url: string; status: number; ms: number }) => void) {
|
||||||
|
* const started = new WeakMap<object, number>();
|
||||||
|
*
|
||||||
|
* return definePlugin({
|
||||||
|
* name: 'logger',
|
||||||
|
* hooks: {
|
||||||
|
* onRequest: (ctx) => {
|
||||||
|
* started.set(ctx, performance.now());
|
||||||
|
* },
|
||||||
|
* onResponse: (ctx) => {
|
||||||
|
* const t = started.get(ctx);
|
||||||
|
* if (t === undefined) return;
|
||||||
|
* sink({ url: String(ctx.request), status: ctx.response.status, ms: performance.now() - t });
|
||||||
|
* },
|
||||||
|
* onResponseError: (ctx) => {
|
||||||
|
* const t = started.get(ctx);
|
||||||
|
* if (t === undefined) return;
|
||||||
|
* sink({ url: String(ctx.request), status: ctx.response.status, ms: performance.now() - t });
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example <caption>Request ID / correlation header</caption>
|
||||||
|
* const requestId = definePlugin<'requestId', { requestId?: string }>({
|
||||||
|
* name: 'requestId',
|
||||||
|
* hooks: {
|
||||||
|
* onRequest: (ctx) => {
|
||||||
|
* const id = (ctx.options as { requestId?: string }).requestId ?? crypto.randomUUID();
|
||||||
|
* ctx.options.headers.set('x-request-id', id);
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example <caption>Composing multiple plugins — order matters</caption>
|
||||||
|
* // Hooks execute in registration order, then any user per-request hook runs last.
|
||||||
|
* // Here: requestId → auth → logger → user-provided onRequest.
|
||||||
|
* const api = createFetch({
|
||||||
|
* plugins: [requestId, createAuthPlugin(fetchToken), createLoggerPlugin(console.log), unwrap],
|
||||||
|
* defaults: { baseURL: 'https://api.example.com' },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Per-domain instance inherits every parent plugin and may add its own.
|
||||||
|
* const billing = api.extend({ baseURL: 'https://billing.example.com' }, {
|
||||||
|
* plugins: [idempotency],
|
||||||
|
* });
|
||||||
|
* await billing('/invoices', { method: 'POST', body: { amount: 100 } });
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function definePlugin<
|
||||||
|
const Name extends string,
|
||||||
|
OptionsExt = unknown,
|
||||||
|
ContextExt = unknown,
|
||||||
|
>(
|
||||||
|
plugin: FetchPlugin<Name, OptionsExt, ContextExt>,
|
||||||
|
): FetchPlugin<Name, OptionsExt, ContextExt> {
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// composePlugins — runs once per createFetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name ComposedPlugins
|
||||||
|
* @category Fetch
|
||||||
|
* @description Flattened hook lists and merged defaults produced by composePlugins.
|
||||||
|
*/
|
||||||
|
export interface ComposedPlugins {
|
||||||
|
/** Merged defaults — plugin defaults first, then user defaults (user wins) */
|
||||||
|
defaults: FetchOptions;
|
||||||
|
/** Pre-flattened readonly hook arrays; undefined when no plugin contributed a phase */
|
||||||
|
readonly hooks: {
|
||||||
|
readonly onRequest: readonly FetchHook[] | undefined;
|
||||||
|
readonly onRequestError: readonly FetchHook[] | undefined;
|
||||||
|
readonly onResponse: readonly FetchHook[] | undefined;
|
||||||
|
readonly onResponseError: readonly FetchHook[] | undefined;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Pre-composed onion chain of plugin `execute` middlewares, or `undefined`
|
||||||
|
* when no plugin contributed one (fast path: caller invokes the core
|
||||||
|
* executor directly without constructing a `next` closure).
|
||||||
|
*/
|
||||||
|
readonly execute: FetchExecuteMiddleware | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty hooks shape reused when no plugins are attached — preserves a single hidden class */
|
||||||
|
const EMPTY_HOOKS: ComposedPlugins['hooks'] = /* @__PURE__ */ Object.freeze({
|
||||||
|
onRequest: undefined,
|
||||||
|
onRequestError: undefined,
|
||||||
|
onResponse: undefined,
|
||||||
|
onResponseError: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
type HeadersInput = Headers | Record<string, string | undefined> | Array<[string, string]>;
|
||||||
|
|
||||||
|
function appendHeaders(target: Headers, source: HeadersInput): void {
|
||||||
|
if (source instanceof Headers) {
|
||||||
|
source.forEach((value, key) => {
|
||||||
|
target.set(key, value);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = new Headers(source as Record<string, string> | Array<[string, string]>);
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
target.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHook<C>(
|
||||||
|
target: Array<FetchHook<C>>,
|
||||||
|
source: FetchHook<C> | ReadonlyArray<FetchHook<C>> | undefined,
|
||||||
|
): void {
|
||||||
|
if (source === undefined) return;
|
||||||
|
if (typeof source === 'function') {
|
||||||
|
target.push(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
target.push(source[i]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDefaults(
|
||||||
|
merged: FetchOptions,
|
||||||
|
mergedHeaders: Headers | undefined,
|
||||||
|
next: FetchOptions,
|
||||||
|
): { defaults: FetchOptions; headers: Headers | undefined } {
|
||||||
|
const { headers, ...rest } = next;
|
||||||
|
const out = { ...merged, ...rest };
|
||||||
|
let nextHeaders = mergedHeaders;
|
||||||
|
if (headers !== undefined) {
|
||||||
|
nextHeaders ??= new Headers();
|
||||||
|
appendHeaders(nextHeaders, headers as HeadersInput);
|
||||||
|
}
|
||||||
|
return { defaults: out, headers: nextHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name composePlugins
|
||||||
|
* @category Fetch
|
||||||
|
* @description Flattens plugin defaults and hook arrays into a single shape suitable
|
||||||
|
* for long-lived storage on a fetch instance. Runs exactly once per createFetch call.
|
||||||
|
*
|
||||||
|
* Ordering: plugin defaults (in declaration order) → user defaults (user wins).
|
||||||
|
* Headers are merged independently through a single Headers instance.
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function composePlugins(
|
||||||
|
plugins: readonly FetchPlugin[] | undefined,
|
||||||
|
userDefaults: FetchOptions | undefined,
|
||||||
|
): ComposedPlugins {
|
||||||
|
// Fast path — no plugins: avoid allocating hook arrays and header instances
|
||||||
|
if (plugins === undefined || plugins.length === 0) {
|
||||||
|
return {
|
||||||
|
defaults: userDefaults ?? {},
|
||||||
|
hooks: EMPTY_HOOKS,
|
||||||
|
execute: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaults: FetchOptions = {};
|
||||||
|
let headers: Headers | undefined;
|
||||||
|
|
||||||
|
const onRequest: FetchHook[] = [];
|
||||||
|
const onRequestError: FetchHook[] = [];
|
||||||
|
const onResponse: FetchHook[] = [];
|
||||||
|
const onResponseError: FetchHook[] = [];
|
||||||
|
const executes: FetchExecuteMiddleware[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < plugins.length; i++) {
|
||||||
|
const plugin = plugins[i]!;
|
||||||
|
|
||||||
|
if (plugin.defaults !== undefined) {
|
||||||
|
const merged = applyDefaults(defaults, headers, plugin.defaults);
|
||||||
|
defaults = merged.defaults;
|
||||||
|
headers = merged.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.hooks !== undefined) {
|
||||||
|
const hooks: FetchHooks = plugin.hooks;
|
||||||
|
pushHook(onRequest, hooks.onRequest as FetchHook | readonly FetchHook[] | undefined);
|
||||||
|
pushHook(onRequestError, hooks.onRequestError as FetchHook | readonly FetchHook[] | undefined);
|
||||||
|
pushHook(onResponse, hooks.onResponse as FetchHook | readonly FetchHook[] | undefined);
|
||||||
|
pushHook(onResponseError, hooks.onResponseError as FetchHook | readonly FetchHook[] | undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.execute !== undefined) {
|
||||||
|
executes.push(plugin.execute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userDefaults !== undefined) {
|
||||||
|
const merged = applyDefaults(defaults, headers, userDefaults);
|
||||||
|
defaults = merged.defaults;
|
||||||
|
headers = merged.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers !== undefined) {
|
||||||
|
defaults = { ...defaults, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke setup AFTER defaults are fully merged, so plugins observe the final shape
|
||||||
|
for (let i = 0; i < plugins.length; i++) {
|
||||||
|
plugins[i]!.setup?.({ defaults });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaults,
|
||||||
|
hooks: {
|
||||||
|
onRequest: onRequest.length > 0 ? onRequest : undefined,
|
||||||
|
onRequestError: onRequestError.length > 0 ? onRequestError : undefined,
|
||||||
|
onResponse: onResponse.length > 0 ? onResponse : undefined,
|
||||||
|
onResponseError: onResponseError.length > 0 ? onResponseError : undefined,
|
||||||
|
},
|
||||||
|
execute: executes.length === 0
|
||||||
|
? undefined
|
||||||
|
: executes.length === 1
|
||||||
|
? executes[0]
|
||||||
|
: composeExecute(executes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classic onion composition — dispatch(i) invokes middleware i or, past the end,
|
||||||
|
* delegates to the supplied `next`. Middlewares MAY call next() multiple times
|
||||||
|
* (retry-style) — the dispatcher is re-entrant.
|
||||||
|
*/
|
||||||
|
function composeExecute(middlewares: readonly FetchExecuteMiddleware[]): FetchExecuteMiddleware {
|
||||||
|
return (context, next) => {
|
||||||
|
const dispatch = (i: number): Promise<void> => {
|
||||||
|
const mw = middlewares[i];
|
||||||
|
if (mw === undefined) return next();
|
||||||
|
return mw(context, () => dispatch(i + 1));
|
||||||
|
};
|
||||||
|
return dispatch(0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// runHookPhase — dispatches instance hooks then optional per-request hook(s)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name runHookPhase
|
||||||
|
* @category Fetch
|
||||||
|
* @description Runs all instance-level (plugin) hooks for a single phase, then the
|
||||||
|
* optional user per-request hook(s). Avoids allocating an intermediate array per call.
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export async function runHookPhase<C>(
|
||||||
|
instance: ReadonlyArray<FetchHook<C>> | undefined,
|
||||||
|
user: FetchHook<C> | ReadonlyArray<FetchHook<C>> | undefined,
|
||||||
|
context: C,
|
||||||
|
): Promise<void> {
|
||||||
|
if (instance !== undefined) {
|
||||||
|
for (let i = 0; i < instance.length; i++) {
|
||||||
|
await instance[i]!(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === undefined) return;
|
||||||
|
|
||||||
|
if (typeof user === 'function') {
|
||||||
|
await user(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < user.length; i++) {
|
||||||
|
await user[i]!(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { retryPlugin } from './retry';
|
||||||
|
export { timeoutPlugin } from './timeout';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { FetchContext, ResolvedFetchOptions } from '../types';
|
||||||
|
import { isFunction, isNumber, retry } from '@robonen/stdlib';
|
||||||
|
import { definePlugin } from '../plugin';
|
||||||
|
import { isPayloadMethod } from '../utils';
|
||||||
|
|
||||||
|
/** HTTP status codes that trigger automatic retry by default */
|
||||||
|
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
||||||
|
408, // Request Timeout
|
||||||
|
409, // Conflict
|
||||||
|
425, // Too Early (Experimental)
|
||||||
|
429, // Too Many Requests
|
||||||
|
500, // Internal Server Error
|
||||||
|
502, // Bad Gateway
|
||||||
|
503, // Service Unavailable
|
||||||
|
504, // Gateway Timeout
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ABORT_ERROR_NAME = 'AbortError';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the number of retries for a given request. Module-scope so the
|
||||||
|
* function site stays monomorphic (always sees `ResolvedFetchOptions` / string).
|
||||||
|
*/
|
||||||
|
function computeMaxRetries(options: ResolvedFetchOptions, method: string): number {
|
||||||
|
const retryOpt = options.retry;
|
||||||
|
if (retryOpt === false) return 0;
|
||||||
|
if (isNumber(retryOpt)) return retryOpt;
|
||||||
|
return isPayloadMethod(method) ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the current response status is in the effective retry-status allowlist. */
|
||||||
|
function shouldRetryStatus(options: ResolvedFetchOptions, status: number): boolean {
|
||||||
|
const list = options.retryStatusCodes;
|
||||||
|
return list !== undefined
|
||||||
|
? list.includes(status)
|
||||||
|
: DEFAULT_RETRY_STATUS_CODES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name retryPlugin
|
||||||
|
* @category Fetch
|
||||||
|
* @description Retries failed attempts based on status code, respecting
|
||||||
|
* `retry` / `retryDelay` / `retryStatusCodes` request options.
|
||||||
|
*
|
||||||
|
* Auto-registered by `createFetch`; disable per-request via `retry: false`.
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function retryPlugin() {
|
||||||
|
return definePlugin({
|
||||||
|
name: 'retry',
|
||||||
|
execute: async (context, next) => {
|
||||||
|
const options = context.options;
|
||||||
|
const maxRetries = computeMaxRetries(options, options.method ?? 'GET');
|
||||||
|
|
||||||
|
// Fast path — no retries requested; avoid the stdlib retry wrapper
|
||||||
|
if (maxRetries === 0) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryDelay = options.retryDelay;
|
||||||
|
const delay = isFunction(retryDelay)
|
||||||
|
? () => (retryDelay as (ctx: FetchContext) => number)(context)
|
||||||
|
: (retryDelay ?? 0);
|
||||||
|
|
||||||
|
await retry(
|
||||||
|
async ({ stop }) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// User-initiated abort must never be retried. `AbortSignal.timeout`
|
||||||
|
// aborts with a `TimeoutError`, so a plain `AbortError` is always
|
||||||
|
// caller-driven and should stop the retry loop immediately.
|
||||||
|
const err = context.error;
|
||||||
|
if (err !== undefined && err.name === ABORT_ERROR_NAME) {
|
||||||
|
stop(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// stdlib retry counts total attempts; fetch `retry` means retries only
|
||||||
|
times: maxRetries + 1,
|
||||||
|
delay,
|
||||||
|
shouldRetry: () => shouldRetryStatus(options, context.response?.status ?? 500),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { definePlugin } from '../plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller's original `signal`, captured once per request so each retry attempt
|
||||||
|
* recombines a *fresh* timeout signal with it instead of reusing an already
|
||||||
|
* aborted one. Keyed on the FetchContext to keep its hidden class stable.
|
||||||
|
*/
|
||||||
|
const baseSignals = new WeakMap<object, AbortSignal | undefined>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name timeoutPlugin
|
||||||
|
* @category Fetch
|
||||||
|
* @description Composes an `AbortSignal.timeout(ms)` with any caller-supplied signal
|
||||||
|
* when `options.timeout` is set.
|
||||||
|
*
|
||||||
|
* Implemented as an `execute` middleware (inner to `retry`) so every retry attempt
|
||||||
|
* gets a brand-new timeout signal — a single timeout no longer poisons all
|
||||||
|
* subsequent attempts. The timeout therefore applies per attempt, not to the whole
|
||||||
|
* retry sequence.
|
||||||
|
*
|
||||||
|
* Auto-registered by `createFetch`; no-op when `timeout` is unset.
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function timeoutPlugin() {
|
||||||
|
return definePlugin({
|
||||||
|
name: 'timeout',
|
||||||
|
execute: async (context, next) => {
|
||||||
|
const options = context.options;
|
||||||
|
const timeout = options.timeout;
|
||||||
|
if (timeout === undefined) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix the caller's signal once; reuse it across retry attempts.
|
||||||
|
let base: AbortSignal | undefined;
|
||||||
|
if (baseSignals.has(context)) {
|
||||||
|
base = baseSignals.get(context);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
base = options.signal as AbortSignal | undefined;
|
||||||
|
baseSignals.set(context, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSignal = AbortSignal.timeout(timeout);
|
||||||
|
options.signal = base === undefined
|
||||||
|
? timeoutSignal
|
||||||
|
: AbortSignal.any([timeoutSignal, base]);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+128
-16
@@ -1,4 +1,4 @@
|
|||||||
import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
|
import type { MaybePromise, ReadonlyArrayable, UnionToIntersection } from '@robonen/stdlib';
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// Fetch API
|
// Fetch API
|
||||||
@@ -8,51 +8,60 @@ import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
|
|||||||
* @name $Fetch
|
* @name $Fetch
|
||||||
* @category Fetch
|
* @category Fetch
|
||||||
* @description The main fetch interface with method shortcuts, raw access, and factory methods
|
* @description The main fetch interface with method shortcuts, raw access, and factory methods
|
||||||
|
*
|
||||||
|
* @typeParam Plugins - Tuple of plugins attached to this instance; their option
|
||||||
|
* extensions are merged into every request's options type.
|
||||||
*/
|
*/
|
||||||
export interface $Fetch {
|
export interface $Fetch<Plugins extends readonly FetchPlugin[] = []> {
|
||||||
<T = unknown, R extends ResponseType = 'json'>(
|
<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: FetchOptions<R, T>,
|
options?: FetchOptions<R, T> & MergePluginOptions<Plugins>,
|
||||||
): Promise<MappedResponseType<R, T>>;
|
): Promise<MappedResponseType<R, T>>;
|
||||||
raw<T = unknown, R extends ResponseType = 'json'>(
|
raw<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: FetchOptions<R, T>,
|
options?: FetchOptions<R, T> & MergePluginOptions<Plugins>,
|
||||||
): Promise<FetchResponse<MappedResponseType<R, T>>>;
|
): Promise<FetchResponse<MappedResponseType<R, T>>>;
|
||||||
/** Access to the underlying native fetch function */
|
/** Access to the underlying native fetch function */
|
||||||
native: Fetch;
|
native: Fetch;
|
||||||
/** Create a new fetch instance with merged defaults */
|
/** Create a new fetch instance with merged defaults and (optionally) additional plugins */
|
||||||
create(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
create<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||||
/** Alias for create — extend this instance with new defaults */
|
defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||||
extend(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
globalOptions?: CreateFetchOptions<NewPlugins>,
|
||||||
|
): $Fetch<[...Plugins, ...NewPlugins]>;
|
||||||
|
/** Alias for create — extend this instance with new defaults and (optionally) additional plugins */
|
||||||
|
extend<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||||
|
defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||||
|
globalOptions?: CreateFetchOptions<NewPlugins>,
|
||||||
|
): $Fetch<[...Plugins, ...NewPlugins]>;
|
||||||
/** Shorthand for GET requests */
|
/** Shorthand for GET requests */
|
||||||
get<T = unknown, R extends ResponseType = 'json'>(
|
get<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||||
): Promise<MappedResponseType<R, T>>;
|
): Promise<MappedResponseType<R, T>>;
|
||||||
/** Shorthand for POST requests */
|
/** Shorthand for POST requests */
|
||||||
post<T = unknown, R extends ResponseType = 'json'>(
|
post<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||||
): Promise<MappedResponseType<R, T>>;
|
): Promise<MappedResponseType<R, T>>;
|
||||||
/** Shorthand for PUT requests */
|
/** Shorthand for PUT requests */
|
||||||
put<T = unknown, R extends ResponseType = 'json'>(
|
put<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||||
): Promise<MappedResponseType<R, T>>;
|
): Promise<MappedResponseType<R, T>>;
|
||||||
/** Shorthand for PATCH requests */
|
/** Shorthand for PATCH requests */
|
||||||
patch<T = unknown, R extends ResponseType = 'json'>(
|
patch<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||||
): Promise<MappedResponseType<R, T>>;
|
): Promise<MappedResponseType<R, T>>;
|
||||||
/** Shorthand for DELETE requests */
|
/** Shorthand for DELETE requests */
|
||||||
delete<T = unknown, R extends ResponseType = 'json'>(
|
delete<T = unknown, R extends ResponseType = 'json'>(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||||
): Promise<MappedResponseType<R, T>>;
|
): Promise<MappedResponseType<R, T>>;
|
||||||
/** Shorthand for HEAD requests */
|
/** Shorthand for HEAD requests */
|
||||||
head(
|
head(
|
||||||
request: FetchRequest,
|
request: FetchRequest,
|
||||||
options?: Omit<FetchOptions, 'method'>,
|
options?: Omit<FetchOptions, 'method'> & MergePluginOptions<Plugins>,
|
||||||
): Promise<FetchResponse<unknown>>;
|
): Promise<FetchResponse<unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,14 +126,117 @@ export interface ResolvedFetchOptions<R extends ResponseType = 'json', T = unkno
|
|||||||
* @name CreateFetchOptions
|
* @name CreateFetchOptions
|
||||||
* @category Fetch
|
* @category Fetch
|
||||||
* @description Global options for createFetch
|
* @description Global options for createFetch
|
||||||
|
*
|
||||||
|
* @typeParam Plugins - Tuple of plugins to attach to the instance
|
||||||
*/
|
*/
|
||||||
export interface CreateFetchOptions {
|
export interface CreateFetchOptions<Plugins extends readonly FetchPlugin[] = []> {
|
||||||
/** Default options merged into every request */
|
/** Default options merged into every request */
|
||||||
defaults?: FetchOptions;
|
defaults?: FetchOptions & MergePluginOptions<Plugins>;
|
||||||
/** Custom fetch implementation — defaults to globalThis.fetch */
|
/** Custom fetch implementation — defaults to globalThis.fetch */
|
||||||
fetch?: Fetch;
|
fetch?: Fetch;
|
||||||
|
/**
|
||||||
|
* Plugins composed once at createFetch time.
|
||||||
|
* Each plugin may contribute defaults, lifecycle hooks, and typed option fields.
|
||||||
|
*/
|
||||||
|
plugins?: Plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// Plugins
|
||||||
|
// --------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name FetchPlugin
|
||||||
|
* @category Fetch
|
||||||
|
* @description A reusable bundle of defaults and lifecycle hooks that extends a fetch instance.
|
||||||
|
*
|
||||||
|
* Plugins are composed once at `createFetch` time — their defaults and hooks are
|
||||||
|
* flattened into the instance closure, so attaching plugins adds zero per-request
|
||||||
|
* overhead beyond the contributed hooks themselves.
|
||||||
|
*
|
||||||
|
* @typeParam Name - Unique plugin identifier (for debugging / duplicate detection)
|
||||||
|
* @typeParam OptionsExt - Extra fields merged into every request's options type
|
||||||
|
* @typeParam ContextExt - Extra fields that the plugin may attach to FetchContext
|
||||||
|
* at runtime (advisory; not enforced by the core pipeline)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const authPlugin = definePlugin<'auth', { token?: string }>({
|
||||||
|
* name: 'auth',
|
||||||
|
* hooks: {
|
||||||
|
* onRequest: (ctx) => {
|
||||||
|
* const token = (ctx.options as { token?: string }).token;
|
||||||
|
* if (token) ctx.options.headers.set('authorization', `Bearer ${token}`);
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export interface FetchPlugin<
|
||||||
|
Name extends string = string,
|
||||||
|
OptionsExt = unknown,
|
||||||
|
ContextExt = unknown,
|
||||||
|
> {
|
||||||
|
/** Plugin identifier */
|
||||||
|
readonly name: Name;
|
||||||
|
/** Default options contributed by the plugin — merged under user defaults */
|
||||||
|
readonly defaults?: FetchOptions;
|
||||||
|
/** Lifecycle hooks executed before any user per-request hooks */
|
||||||
|
readonly hooks?: FetchHooks;
|
||||||
|
/**
|
||||||
|
* Onion-style middleware wrapping the fetch attempt + response parse.
|
||||||
|
* Plugins compose in registration order; calling `next()` invokes the next
|
||||||
|
* middleware or ultimately the core executor. May call `next()` multiple
|
||||||
|
* times (e.g. to implement retries).
|
||||||
|
*/
|
||||||
|
readonly execute?: FetchExecuteMiddleware;
|
||||||
|
/** Invoked once per createFetch, after all plugin defaults are merged */
|
||||||
|
readonly setup?: (context: { readonly defaults: FetchOptions }) => void;
|
||||||
|
/**
|
||||||
|
* Phantom marker for type-only option/context extensions — never present at runtime.
|
||||||
|
* Populated via dummy field in `definePlugin` generics.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
readonly __types?: { options: OptionsExt; context: ContextExt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name FetchExecuteMiddleware
|
||||||
|
* @category Fetch
|
||||||
|
* @description Onion-style wrapper around a single fetch attempt.
|
||||||
|
*
|
||||||
|
* Invoking `next()` delegates to the next middleware in the chain or, at the
|
||||||
|
* innermost layer, performs the actual `fetch` call and response body parsing.
|
||||||
|
* `context.response` / `context.error` are populated by the time `next()` resolves.
|
||||||
|
*
|
||||||
|
* Middlewares may call `next()` zero, one, or many times (retries).
|
||||||
|
*/
|
||||||
|
export type FetchExecuteMiddleware = (
|
||||||
|
context: FetchContext,
|
||||||
|
next: () => Promise<void>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name MergePluginOptions
|
||||||
|
* @category Fetch
|
||||||
|
* @description Intersection of all `OptionsExt` carried by a plugin tuple. Empty tuple resolves to `unknown`.
|
||||||
|
*/
|
||||||
|
export type MergePluginOptions<Plugins extends readonly FetchPlugin[]>
|
||||||
|
= [Plugins[number]] extends [never]
|
||||||
|
? unknown
|
||||||
|
: UnionToIntersection<PluginOptionsOf<Plugins[number]>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name MergePluginContext
|
||||||
|
* @category Fetch
|
||||||
|
* @description Intersection of all `ContextExt` carried by a plugin tuple. Empty tuple resolves to `unknown`.
|
||||||
|
*/
|
||||||
|
export type MergePluginContext<Plugins extends readonly FetchPlugin[]>
|
||||||
|
= [Plugins[number]] extends [never]
|
||||||
|
? unknown
|
||||||
|
: UnionToIntersection<PluginContextOf<Plugins[number]>>;
|
||||||
|
|
||||||
|
type PluginOptionsOf<P> = P extends FetchPlugin<infer _N, infer O, infer _C> ? O : unknown;
|
||||||
|
type PluginContextOf<P> = P extends FetchPlugin<infer _N, infer _O, infer C> ? C : unknown;
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// Hooks and Context
|
// Hooks and Context
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ describe('buildURL', () => {
|
|||||||
it('returns the URL unchanged when all params are omitted', () => {
|
it('returns the URL unchanged when all params are omitted', () => {
|
||||||
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
|
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('inserts the query string before a fragment', () => {
|
||||||
|
expect(buildURL('https://api.example.com/p#section', { a: 1 })).toBe(
|
||||||
|
'https://api.example.com/p?a=1#section',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends to an existing query string before a fragment', () => {
|
||||||
|
expect(buildURL('https://api.example.com/p?foo=bar#section', { baz: 'qux' })).toBe(
|
||||||
|
'https://api.example.com/p?foo=bar&baz=qux#section',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+13
-5
@@ -66,8 +66,9 @@ export function isJSONSerializable(value: unknown): boolean {
|
|||||||
// Arrays are serialisable
|
// Arrays are serialisable
|
||||||
if (isArray(value)) return true;
|
if (isArray(value)) return true;
|
||||||
|
|
||||||
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
|
// TypedArrays and ArrayBuffers — use native type checks instead of probing
|
||||||
if ((value as Record<string, unknown>).buffer !== undefined) return false;
|
// `.buffer`, which would pollute the property IC with every distinct body shape.
|
||||||
|
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return false;
|
||||||
|
|
||||||
// FormData and URLSearchParams should not be auto-serialised
|
// FormData and URLSearchParams should not be auto-serialised
|
||||||
if (value instanceof FormData || value instanceof URLSearchParams) return false;
|
if (value instanceof FormData || value instanceof URLSearchParams) return false;
|
||||||
@@ -77,7 +78,7 @@ export function isJSONSerializable(value: unknown): boolean {
|
|||||||
return (
|
return (
|
||||||
ctor === undefined
|
ctor === undefined
|
||||||
|| ctor === Object
|
|| ctor === Object
|
||||||
|| typeof (value as Record<string, unknown>).toJSON === 'function'
|
|| typeof (value as { toJSON?: unknown }).toJSON === 'function'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,9 @@ export function isJSONSerializable(value: unknown): boolean {
|
|||||||
export function detectResponseType(contentType = ''): ResponseType {
|
export function detectResponseType(contentType = ''): ResponseType {
|
||||||
if (!contentType) return 'json';
|
if (!contentType) return 'json';
|
||||||
|
|
||||||
const type = contentType.split(';')[0] ?? '';
|
// Strip any `; charset=...` suffix without allocating an intermediate array.
|
||||||
|
const semi = contentType.indexOf(';');
|
||||||
|
const type = semi === -1 ? contentType : contentType.slice(0, semi);
|
||||||
|
|
||||||
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
|
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
|
||||||
if (type === 'text/event-stream') return 'stream';
|
if (type === 'text/event-stream') return 'stream';
|
||||||
@@ -140,7 +143,12 @@ export function buildURL(
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
if (!qs) return url;
|
if (!qs) return url;
|
||||||
|
|
||||||
return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`;
|
// Insert the query string *before* any fragment so `#section` stays trailing.
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
||||||
|
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
||||||
|
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${qs}${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.src.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["*.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ import { sharedConfig } from '@robonen/tsdown';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
...sharedConfig,
|
...sharedConfig,
|
||||||
|
tsconfig: './tsconfig.src.json',
|
||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user