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:
2026-06-07 16:29:18 +07:00
parent 96f4cba4a8
commit a7e668ced8
19 changed files with 1759 additions and 233 deletions
+92
View File
@@ -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
View File
@@ -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;
}
+7
View File
@@ -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,
+380
View File
@@ -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 }>();
});
});
+357
View File
@@ -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);
}
}
+2
View File
@@ -0,0 +1,2 @@
export { retryPlugin } from './retry';
export { timeoutPlugin } from './timeout';
+92
View File
@@ -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),
},
);
},
});
}
+54
View File
@@ -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
View File
@@ -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
// --------------------------
+12
View File
@@ -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
View File
@@ -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}`;
}
/**