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": {
|
||||
"lint:check": "oxlint -c oxlint.config.ts",
|
||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||
"lint:check": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
@@ -46,11 +46,10 @@
|
||||
"@robonen/stdlib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/eslint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,21 @@ describe('JSON body serialisation', () => {
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
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(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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+216
-202
@@ -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 { composePlugins, runHookPhase } from './plugin';
|
||||
import { retryPlugin, timeoutPlugin } from './plugins';
|
||||
import {
|
||||
NULL_BODY_STATUSES,
|
||||
buildURL,
|
||||
callHooks,
|
||||
detectResponseType,
|
||||
isJSONSerializable,
|
||||
isPayloadMethod,
|
||||
joinURL,
|
||||
resolveFetchOptions,
|
||||
} 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([
|
||||
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
|
||||
]);
|
||||
/**
|
||||
* Serialize the request body for payload-bearing methods and set the
|
||||
* appropriate content-type / accept / duplex hints. Mutates `options` in place.
|
||||
*/
|
||||
function serializeRequestBody(options: ResolvedFetchOptions<ResponseType, unknown>, method: string): void {
|
||||
const body = options.body;
|
||||
if (body === undefined || body === null || !isPayloadMethod(method)) return;
|
||||
|
||||
if (isJSONSerializable(body)) {
|
||||
// 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
|
||||
@@ -37,94 +132,85 @@ const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set(
|
||||
* @category Fetch
|
||||
* @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
|
||||
*
|
||||
* @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;
|
||||
|
||||
// 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>> {
|
||||
// Actual fetch call
|
||||
async function runAttempt<T, R extends ResponseType>(context: FetchContext<T, R>): Promise<void> {
|
||||
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 {
|
||||
context.response = await fetchImpl(context.request, context.options as RequestInit);
|
||||
context.response = await fetchImpl(context.request, options as RequestInit);
|
||||
}
|
||||
catch (err) {
|
||||
context.error = err as Error;
|
||||
|
||||
if (context.options.onRequestError !== undefined) {
|
||||
await callHooks(
|
||||
if (composedHooks.onRequestError !== undefined || options.onRequestError !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onRequestError as ReadonlyArray<FetchHook<FetchContext<T, R> & { error: Error }>> | undefined,
|
||||
options.onRequestError,
|
||||
context as FetchContext<T, R> & { error: Error },
|
||||
context.options.onRequestError,
|
||||
);
|
||||
}
|
||||
|
||||
throw createFetchError(context);
|
||||
}
|
||||
|
||||
// Response body parsing
|
||||
const method = context.options.method ?? 'GET';
|
||||
const hasBody
|
||||
= context.response.body !== null
|
||||
&& !NULL_BODY_STATUSES.has(context.response.status)
|
||||
&& method !== 'HEAD';
|
||||
const response = context.response;
|
||||
const method = options.method ?? DEFAULT_METHOD;
|
||||
|
||||
if (hasBody) {
|
||||
const responseType
|
||||
= context.options.parseResponse !== undefined
|
||||
? 'json'
|
||||
: (context.options.responseType
|
||||
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
|
||||
await parseResponseBody(response as FetchResponse<unknown>, options as unknown as ResolvedFetchOptions<ResponseType, unknown>, method);
|
||||
|
||||
switch (responseType) {
|
||||
case 'json': {
|
||||
const text = await context.response.text();
|
||||
if (text) {
|
||||
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(
|
||||
if (composedHooks.onResponse !== undefined || options.onResponse !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onResponse as ReadonlyArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>> | undefined,
|
||||
options.onResponse,
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!context.options.ignoreResponseError
|
||||
&& context.response.status >= 400
|
||||
&& context.response.status < 600
|
||||
) {
|
||||
if (context.options.onResponseError !== undefined) {
|
||||
await callHooks(
|
||||
const status = response.status;
|
||||
if (!options.ignoreResponseError && status >= 400 && status < 600) {
|
||||
if (composedHooks.onResponseError !== undefined || options.onResponseError !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onResponseError as ReadonlyArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>> | undefined,
|
||||
options.onResponseError,
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponseError,
|
||||
);
|
||||
}
|
||||
|
||||
throw createFetchError(context);
|
||||
}
|
||||
|
||||
return context.response;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -138,149 +224,69 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
_request: FetchRequest,
|
||||
_options?: FetchOptions<R, T>,
|
||||
): Promise<FetchResponse<MappedResponseType<R, T>>> {
|
||||
// Fixed key order → single hidden class for FetchContext across all requests
|
||||
const context: FetchContext<T, R> = {
|
||||
request: _request,
|
||||
options: resolveFetchOptions(
|
||||
_request,
|
||||
_options,
|
||||
globalOptions.defaults,
|
||||
),
|
||||
options: resolveFetchOptions(_request, _options, composedDefaults),
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
// Normalise method to uppercase before any hook or header logic
|
||||
if (context.options.method !== undefined) {
|
||||
context.options.method = context.options.method.toUpperCase();
|
||||
const options = context.options;
|
||||
|
||||
// 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) {
|
||||
await callHooks(context, context.options.onRequest);
|
||||
if (composedHooks.onRequest !== undefined || options.onRequest !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onRequest as ReadonlyArray<FetchHook<FetchContext<T, R>>> | undefined,
|
||||
options.onRequest,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// URL transformations — only when request is a plain string
|
||||
if (isString(context.request)) {
|
||||
if (context.options.baseURL !== undefined) {
|
||||
context.request = joinURL(context.options.baseURL, context.request);
|
||||
if (options.baseURL !== undefined) {
|
||||
context.request = joinURL(options.baseURL, context.request);
|
||||
}
|
||||
|
||||
const query = context.options.query ?? context.options.params;
|
||||
const query = options.query ?? options.params;
|
||||
if (query !== undefined) {
|
||||
context.request = buildURL(context.request, query);
|
||||
}
|
||||
}
|
||||
|
||||
// Body serialisation
|
||||
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;
|
||||
}
|
||||
serializeRequestBody(options as unknown as ResolvedFetchOptions<ResponseType, unknown>, method);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 {
|
||||
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
||||
}
|
||||
catch (err) {
|
||||
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||
|
||||
if (isFunction(Error.captureStackTrace)) {
|
||||
Error.captureStackTrace(error, $fetchRaw);
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (composedExecute === undefined) {
|
||||
await runAttempt(context);
|
||||
}
|
||||
else {
|
||||
await composedExecute(context as unknown as FetchContext, () => runAttempt(context));
|
||||
}
|
||||
return context.response as FetchResponse<MappedResponseType<R, T>>;
|
||||
}
|
||||
catch (err) {
|
||||
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||
|
||||
// V8 / Node.js — clip internal frames from the error stack trace
|
||||
if (isFunction(Error.captureStackTrace)) {
|
||||
Error.captureStackTrace(error, $fetchRaw);
|
||||
if (captureStackTrace !== undefined) {
|
||||
captureStackTrace(error, $fetchRaw);
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -297,35 +303,43 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
): Promise<MappedResponseType<R, T>> {
|
||||
const response = await $fetchRaw<T, R>(request, options);
|
||||
return response._data as MappedResponseType<R, T>;
|
||||
} as $Fetch;
|
||||
} as $Fetch<Plugins>;
|
||||
|
||||
$fetch.raw = $fetchRaw;
|
||||
|
||||
$fetch.native = (...args: Parameters<typeof fetchImpl>) => fetchImpl(...args);
|
||||
$fetch.native = fetchImpl as Fetch;
|
||||
|
||||
$fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) =>
|
||||
createFetch({
|
||||
...globalOptions,
|
||||
...customGlobalOptions,
|
||||
$fetch.create = (<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||
defaults: FetchOptions = {},
|
||||
customGlobalOptions: CreateFetchOptions<NewPlugins> = {},
|
||||
) =>
|
||||
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: {
|
||||
...globalOptions.defaults,
|
||||
...customGlobalOptions.defaults,
|
||||
...(globalOptions.defaults as FetchOptions | undefined),
|
||||
...(customGlobalOptions.defaults as FetchOptions | undefined),
|
||||
...defaults,
|
||||
},
|
||||
});
|
||||
} as FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||
})) as $Fetch<Plugins>['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' });
|
||||
$fetch.post = (request, options) => $fetch(request, { ...options, method: 'POST' });
|
||||
$fetch.put = (request, options) => $fetch(request, { ...options, method: 'PUT' });
|
||||
$fetch.patch = (request, options) => $fetch(request, { ...options, method: 'PATCH' });
|
||||
$fetch.delete = (request, options) => $fetch(request, { ...options, method: 'DELETE' });
|
||||
$fetch.head = (request, options) => $fetchRaw(request, { ...options, method: 'HEAD' });
|
||||
type ShortcutOptions = FetchOptions & MergePluginOptions<Plugins>;
|
||||
$fetch.get = ((req, opt) => $fetch(req, withMethod(opt, 'GET') as ShortcutOptions)) as $Fetch<Plugins>['get'];
|
||||
$fetch.post = ((req, opt) => $fetch(req, withMethod(opt, 'POST') as ShortcutOptions)) as $Fetch<Plugins>['post'];
|
||||
$fetch.put = ((req, opt) => $fetch(req, withMethod(opt, 'PUT') as ShortcutOptions)) as $Fetch<Plugins>['put'];
|
||||
$fetch.patch = ((req, opt) => $fetch(req, withMethod(opt, 'PATCH') as ShortcutOptions)) as $Fetch<Plugins>['patch'];
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createFetch } from './fetch';
|
||||
|
||||
export { createFetch } from './fetch';
|
||||
export { FetchError, createFetchError } from './error';
|
||||
export { composePlugins, definePlugin, runHookPhase } from './plugin';
|
||||
export type { ComposedPlugins } from './plugin';
|
||||
export { retryPlugin, timeoutPlugin } from './plugins';
|
||||
export {
|
||||
isPayloadMethod,
|
||||
isJSONSerializable,
|
||||
@@ -17,13 +20,17 @@ export type {
|
||||
Fetch,
|
||||
FetchContext,
|
||||
FetchErrorOptions,
|
||||
FetchExecuteMiddleware,
|
||||
FetchHook,
|
||||
FetchHooks,
|
||||
FetchOptions,
|
||||
FetchPlugin,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
IFetchError,
|
||||
MappedResponseType,
|
||||
MergePluginContext,
|
||||
MergePluginOptions,
|
||||
ResponseMap,
|
||||
ResponseType,
|
||||
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
|
||||
@@ -8,51 +8,60 @@ import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
|
||||
* @name $Fetch
|
||||
* @category Fetch
|
||||
* @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'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
options?: FetchOptions<R, T> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
raw<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
options?: FetchOptions<R, T> & MergePluginOptions<Plugins>,
|
||||
): Promise<FetchResponse<MappedResponseType<R, T>>>;
|
||||
/** Access to the underlying native fetch function */
|
||||
native: Fetch;
|
||||
/** Create a new fetch instance with merged defaults */
|
||||
create(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Alias for create — extend this instance with new defaults */
|
||||
extend(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Create a new fetch instance with merged defaults and (optionally) additional plugins */
|
||||
create<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||
defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||
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 */
|
||||
get<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for POST requests */
|
||||
post<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PUT requests */
|
||||
put<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PATCH requests */
|
||||
patch<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for DELETE requests */
|
||||
delete<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for HEAD requests */
|
||||
head(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions, 'method'>,
|
||||
options?: Omit<FetchOptions, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<FetchResponse<unknown>>;
|
||||
}
|
||||
|
||||
@@ -117,14 +126,117 @@ export interface ResolvedFetchOptions<R extends ResponseType = 'json', T = unkno
|
||||
* @name CreateFetchOptions
|
||||
* @category Fetch
|
||||
* @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 */
|
||||
defaults?: FetchOptions;
|
||||
defaults?: FetchOptions & MergePluginOptions<Plugins>;
|
||||
/** Custom fetch implementation — defaults to globalThis.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
|
||||
// --------------------------
|
||||
|
||||
@@ -131,6 +131,18 @@ describe('buildURL', () => {
|
||||
it('returns the URL unchanged when all params are omitted', () => {
|
||||
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
|
||||
if (isArray(value)) return true;
|
||||
|
||||
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
|
||||
if ((value as Record<string, unknown>).buffer !== undefined) return false;
|
||||
// TypedArrays and ArrayBuffers — use native type checks instead of probing
|
||||
// `.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
|
||||
if (value instanceof FormData || value instanceof URLSearchParams) return false;
|
||||
@@ -77,7 +78,7 @@ export function isJSONSerializable(value: unknown): boolean {
|
||||
return (
|
||||
ctor === undefined
|
||||
|| 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 {
|
||||
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 (type === 'text/event-stream') return 'stream';
|
||||
@@ -140,7 +143,12 @@ export function buildURL(
|
||||
const qs = params.toString();
|
||||
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({
|
||||
...sharedConfig,
|
||||
tsconfig: './tsconfig.src.json',
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user